feat(M6): broadcast input + main-process ZMODEM + quick input panel + shortcuts
- broadcast: session target picker popover, fan-out write path, <2 targets auto-disable, per-tab target dots - zmodem: main-process engine over ssh sessions (rz send / sz receive), progress reuses transfer panel, offer 120s / stall 90s watchdogs, e2e test with real zmodem.js peer (both directions) - quick input panel: bottom-right overlay (command line + auto-\r + library + recent), broadcast-aware send - shortcuts: Ctrl+=/-/0 font size (persisted), globalShowHide accelerator via globalShortcut, Ctrl+PgUp/PgDn terminal cycling - contracts: ZMODEM_OFFER/RESPOND/DONE channels, SystemSettings.globalShowHide, TransferKind zmodem-upload/download - review fixes: xterm-helper-textarea font hotkey pass-through, zmodem error-vs-cancelled progress state, offer timer clear, receive stream destroy
This commit is contained in:
1 parent
0953ac4f41
commit
0663e5affa
30 files changed
+9028
-37
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// src/renderer/src/terminal/highlightEngine.ts
|
||||
var ESCAPE_RE = /(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g;
|
||||
var MAX_PER_RULE = 300;
|
||||
var MAX_CHUNK = 512 * 1024;
|
||||
function hexToRgb(hex) {
|
||||
let h = (hex || "").trim().replace(/^#/, "");
|
||||
if (h.length === 3) h = h.replace(/./g, (c) => c + c);
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 255, g: 255, b: 255 };
|
||||
const n = parseInt(h, 16);
|
||||
return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
|
||||
}
|
||||
function tokenize(input) {
|
||||
const out = [];
|
||||
let acc = "";
|
||||
let i = 0;
|
||||
let m;
|
||||
while ((m = ESCAPE_RE.exec(input)) !== null) {
|
||||
if (m.index > i) acc += input.slice(i, m.index);
|
||||
if (acc.length > 0) {
|
||||
out.push({ kind: "text", value: acc });
|
||||
acc = "";
|
||||
}
|
||||
out.push({ kind: "seq", value: m[0] });
|
||||
i = m.index + m[0].length;
|
||||
}
|
||||
if (i < input.length) acc += input.slice(i);
|
||||
if (acc.length > 0) out.push({ kind: "text", value: acc });
|
||||
return out;
|
||||
}
|
||||
function compileRules(rules2) {
|
||||
const out = [];
|
||||
for (const rule of rules2) {
|
||||
if (!rule || rule.enabled === false) continue;
|
||||
const pattern = rule.pattern;
|
||||
if (typeof pattern !== "string" || pattern.length === 0) continue;
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(pattern, "g");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fg = typeof rule.color?.fg === "string" ? rule.color.fg : "";
|
||||
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue;
|
||||
const bg = typeof rule.color?.bg === "string" ? rule.color.bg : void 0;
|
||||
out.push({ id: rule.id, regex, fg, bg, priority: rule.priority });
|
||||
}
|
||||
out.sort((a, b) => a.priority - b.priority);
|
||||
return out;
|
||||
}
|
||||
function wrap(text, fgHex, bgHex) {
|
||||
const f = hexToRgb(fgHex);
|
||||
let s = `\x1B[38;2;${f.r};${f.g};${f.b}`;
|
||||
if (bgHex) {
|
||||
const b = hexToRgb(bgHex);
|
||||
s += `;48;2;${b.r};${b.g};${b.b}`;
|
||||
}
|
||||
return `${s}m${text}\x1B[0m`;
|
||||
}
|
||||
function overlaps(claimed, s, e) {
|
||||
for (const c of claimed) {
|
||||
if (s < c.end && e > c.start) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function applyRun(text, rules2, budgets) {
|
||||
if (text.length === 0) return "";
|
||||
const claimed = [];
|
||||
for (let ri = 0; ri < rules2.length; ri++) {
|
||||
const rule = rules2[ri];
|
||||
if (budgets[ri] <= 0) continue;
|
||||
rule.regex.lastIndex = 0;
|
||||
let m;
|
||||
let made = 0;
|
||||
while ((m = rule.regex.exec(text)) !== null) {
|
||||
const start = m.index;
|
||||
const end = start + m[0].length;
|
||||
if (end <= start) {
|
||||
rule.regex.lastIndex++;
|
||||
continue;
|
||||
}
|
||||
if (!overlaps(claimed, start, end)) {
|
||||
claimed.push({ start, end, fg: rule.fg, bg: rule.bg });
|
||||
made++;
|
||||
budgets[ri]--;
|
||||
if (budgets[ri] <= 0) break;
|
||||
}
|
||||
if (rule.regex.lastIndex === start) rule.regex.lastIndex++;
|
||||
}
|
||||
}
|
||||
if (claimed.length === 0) return text;
|
||||
claimed.sort((a, b) => a.start - b.start);
|
||||
let out = "";
|
||||
let pos = 0;
|
||||
for (const c of claimed) {
|
||||
if (c.start > pos) out += text.slice(pos, c.start);
|
||||
out += wrap(text.slice(c.start, c.end), c.fg, c.bg);
|
||||
pos = c.end;
|
||||
}
|
||||
if (pos < text.length) out += text.slice(pos);
|
||||
return out;
|
||||
}
|
||||
function applyHighlights(chunk, rules2) {
|
||||
if (!chunk || rules2.length === 0) return chunk;
|
||||
if (chunk.length > MAX_CHUNK) return chunk;
|
||||
let budgets = rules2.map(() => MAX_PER_RULE);
|
||||
let usedBudget = false;
|
||||
let out = "";
|
||||
for (const token of tokenize(chunk)) {
|
||||
if (token.kind === "seq") {
|
||||
out += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.value.includes("\x1B")) {
|
||||
out += token.value;
|
||||
continue;
|
||||
}
|
||||
const before = budgets.reduce((a, b) => a + b, 0);
|
||||
out += applyRun(token.value, rules2, budgets);
|
||||
const after = budgets.reduce((a, b) => a + b, 0);
|
||||
if (after < before) usedBudget = true;
|
||||
}
|
||||
return usedBudget ? out : chunk;
|
||||
}
|
||||
function isIncompleteEscape(seg) {
|
||||
if (seg === "\x1B") return true;
|
||||
if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true;
|
||||
if (seg.startsWith("\x1B]") && !seg.includes("\x07") && !seg.includes("\x1B\\")) return true;
|
||||
return false;
|
||||
}
|
||||
function lastEscapeEnd(input) {
|
||||
let end = -1;
|
||||
let m;
|
||||
ESCAPE_RE.lastIndex = 0;
|
||||
while ((m = ESCAPE_RE.exec(input)) !== null) end = m.index + m[0].length;
|
||||
return end;
|
||||
}
|
||||
function findIncompleteIndex(input) {
|
||||
const idx = input.lastIndexOf("\x1B");
|
||||
if (idx < 0) return -1;
|
||||
return isIncompleteEscape(input.slice(idx)) ? idx : -1;
|
||||
}
|
||||
var MAX_CARRY = 1024;
|
||||
var HighlightStream = class {
|
||||
rules;
|
||||
buffer = "";
|
||||
constructor(rules2) {
|
||||
this.rules = rules2;
|
||||
}
|
||||
/** Swap the active rules without touching any buffered content. */
|
||||
setRules(rules2) {
|
||||
this.rules = rules2;
|
||||
}
|
||||
/** True while content is held back (a pending flush will release it). */
|
||||
hasPending() {
|
||||
return this.buffer.length > 0;
|
||||
}
|
||||
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
|
||||
push(chunk) {
|
||||
this.buffer += chunk;
|
||||
const incompleteAt = findIncompleteIndex(this.buffer);
|
||||
if (incompleteAt >= 0) {
|
||||
const head2 = this.buffer.slice(0, incompleteAt);
|
||||
const tail = this.buffer.slice(incompleteAt);
|
||||
if (tail.length > MAX_CARRY) {
|
||||
this.buffer = "";
|
||||
return applyHighlights(head2, this.rules) + tail;
|
||||
}
|
||||
this.buffer = tail;
|
||||
return applyHighlights(head2, this.rules);
|
||||
}
|
||||
const lastEnd = lastEscapeEnd(this.buffer);
|
||||
if (lastEnd < 0) {
|
||||
if (this.buffer.length > MAX_CHUNK) {
|
||||
const raw = this.buffer;
|
||||
this.buffer = "";
|
||||
return applyHighlights(raw, this.rules);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
const head = this.buffer.slice(0, lastEnd);
|
||||
this.buffer = this.buffer.slice(lastEnd);
|
||||
return applyHighlights(head, this.rules);
|
||||
}
|
||||
/** Flush any held content (session end) — highlighted as a final run. */
|
||||
flush() {
|
||||
const b = this.buffer;
|
||||
this.buffer = "";
|
||||
return applyHighlights(b, this.rules);
|
||||
}
|
||||
};
|
||||
|
||||
// tests/hl-split-smoke.mjs
|
||||
var rules = compileRules([
|
||||
{ id: "okstate", pattern: "\\b(SUCCESS|PASS|OK|DONE)\\b", enabled: true, priority: 5, color: { fg: "#3fb950" } },
|
||||
{ id: "badstate", pattern: "\\b(ERROR|FAILED)\\b", enabled: true, priority: 6, color: { fg: "#f85149" } },
|
||||
{ id: "numbers", pattern: "\\b\\d+(?:\\.\\d+)?(?:%)?\\b", enabled: true, priority: 25, color: { fg: "#f2cc60" } },
|
||||
{ id: "url", pattern: "https?://[^\\s]+", enabled: true, priority: 30, color: { fg: "#58a6ff" } }
|
||||
]);
|
||||
var banner = "\x1B]0;kimi \u2014 session\x07\x1B[38;2;79;168;255m\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E\x1B[0m\r\n\x1B[38;2;136;136;136mWelcome to Kimi Code!\x1B[0m \x1B[38;2;79;168;255m\u2502\x1B[0m send /help\r\n\x1B[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1B[0m\r\n";
|
||||
var reference = applyHighlights(banner, rules);
|
||||
var failed = 0;
|
||||
var splitAt = (s, i) => [s.slice(0, i), s.slice(i)];
|
||||
for (let i = 1; i < banner.length - 1; i++) {
|
||||
const [a, b] = splitAt(banner, i);
|
||||
const stream = new HighlightStream(rules);
|
||||
const out = stream.push(a) + stream.push(b) + stream.flush();
|
||||
if (out !== reference) {
|
||||
failed++;
|
||||
if (failed <= 3) {
|
||||
console.log(`MISMATCH at split ${i}:`);
|
||||
console.log(" got: " + JSON.stringify(out.slice(0, 120)));
|
||||
console.log(" expected: " + JSON.stringify(reference.slice(0, 120)));
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
const i = Math.floor(banner.length / 3);
|
||||
const j = Math.floor(banner.length * 2 / 3);
|
||||
const stream = new HighlightStream(rules);
|
||||
const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush();
|
||||
if (out !== reference) failed++;
|
||||
}
|
||||
{
|
||||
const stream = new HighlightStream(rules);
|
||||
let out = "";
|
||||
for (const ch of banner) out += stream.push(ch);
|
||||
out += stream.flush();
|
||||
if (out !== reference) failed++;
|
||||
}
|
||||
if (failed === 0) {
|
||||
console.log("SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`);
|
||||
process.exit(1);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -72,6 +72,7 @@ ok(store.listHistory().length === 0, 'clearHistory empties history')
|
||||
|
||||
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
||||
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
|
||||
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
|
||||
const b = store.saveLibraryItem({ command: 'df -h', name: 'disk', group: 'ops' })
|
||||
ok(typeof a.id === 'string' && a.id.length > 0, 'saveLibraryItem assigns an id + createdAt')
|
||||
let lib = store.listLibrary()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* ZMODEM engine e2e (M6). In-process, no SSH server and no real credentials:
|
||||
* our engine (src/main/zmodem.ts) is attached to one end of an in-process wire,
|
||||
* and a SEPARATE zmodem.js Sentry plays the remote rz/sz on the other end. Two
|
||||
* full duplex directions are exercised over the real ZMODEM framing:
|
||||
*
|
||||
* 场景 1 (download): remote `sz` sends a file -> our engine receives it into
|
||||
* a temp dir; assert content matches + done(ok) + progress.
|
||||
* 场景 2 (upload): our engine uploads a local temp file -> remote `rz`
|
||||
* receives it; assert remote content matches + done(ok).
|
||||
*
|
||||
* Pre-req (the bundle is built automatically on first run):
|
||||
* npx esbuild src/main/zmodem.ts --bundle --platform=node --format=cjs \
|
||||
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs \
|
||||
* --outfile=tests/.zmodem-e2e.cjs
|
||||
* zmodem.js stays bundled (NOT external).
|
||||
*
|
||||
* Run: node tests/zmodem-e2e.mjs (must exit 0)
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
existsSync
|
||||
} from 'fs'
|
||||
import { join, dirname, resolve, basename } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const fail = (msg) => {
|
||||
console.error(`FAIL: ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ---- 0. ensure bundle ---------------------------------------------------------
|
||||
const bundlePath = join(__dirname, '.zmodem-e2e.cjs')
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.log('[zmodem-e2e] building bundle ...')
|
||||
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
execFileSync(
|
||||
cmd,
|
||||
[
|
||||
'esbuild', resolve(__dirname, '../src/main/zmodem.ts'),
|
||||
'--bundle', '--platform=node', '--format=cjs',
|
||||
'--external:ssh2',
|
||||
'--alias:electron=./tests/electron-stub.cjs',
|
||||
`--outfile=${bundlePath}`
|
||||
],
|
||||
{ stdio: 'inherit', shell: process.platform === 'win32' }
|
||||
)
|
||||
}
|
||||
|
||||
const zmodem = require_('zmodem.js')
|
||||
const engine = require_(bundlePath)
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Drive a zmodem.js SEND session with a real file (the fs-port of the browser
|
||||
* send_files helper, used by the remote half of the download scenario).
|
||||
*/
|
||||
async function driveRemoteSend(session, filePath) {
|
||||
const data = readFileSync(filePath)
|
||||
const name = basename(filePath)
|
||||
const xfer = await session.send_offer({ name, size: data.length, mtime: new Date() })
|
||||
if (!xfer) throw new Error('offer was skipped')
|
||||
const CHUNK = 8192
|
||||
let off = 0
|
||||
if (data.length === 0) {
|
||||
await xfer.end(new Uint8Array(0))
|
||||
} else {
|
||||
while (off < data.length) {
|
||||
const end = Math.min(off + CHUNK, data.length)
|
||||
if (end < data.length) {
|
||||
xfer.send(new Uint8Array(data.buffer, data.byteOffset + off, end - off))
|
||||
} else {
|
||||
await xfer.end(new Uint8Array(data.buffer, data.byteOffset + off, end - off))
|
||||
}
|
||||
off = end
|
||||
}
|
||||
}
|
||||
await session.close()
|
||||
}
|
||||
|
||||
/** Read all bytes a remote receive side has drained so far. */
|
||||
function collectRemote(self) {
|
||||
return Buffer.concat(self._incoming)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a remote (peer) Sentry. `remoteOnDetect(confirm)` wires the remote's
|
||||
* on_detect: it should confirm + drive its side. `routeOut` is called with the
|
||||
* octets the remote sent so we can feed them into our engine.
|
||||
*/
|
||||
function makeRemoteSentry(remoteOnDetect, routeOut) {
|
||||
const self = { _incoming: [] }
|
||||
const remoteSentry = new zmodem.Sentry({
|
||||
to_terminal: () => {},
|
||||
on_retract: () => {},
|
||||
on_detect: (detection) => remoteOnDetect(detection, self),
|
||||
sender: (octets) => routeOut(Buffer.from(octets))
|
||||
})
|
||||
self.sentry = remoteSentry
|
||||
self.consume = (buf) => {
|
||||
self._incoming.push(Buffer.from(buf))
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
// ---- SCENARIO 1: download (remote `sz` -> our engine receives) ----------------
|
||||
async function scenarioDownload() {
|
||||
console.log('\n=== 场景 1: 远端 sz + 本地接收 (download) ===')
|
||||
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
||||
const srcDir = mkdtempSync(join(__dirname, '.zm-src-dl-'))
|
||||
const srcFile = join(srcDir, 'transferred.bin')
|
||||
const payload = randomBytes(200_000)
|
||||
writeFileSync(srcFile, payload)
|
||||
const outDir = mkdtempSync(join(__dirname, '.zm-out-dl-'))
|
||||
|
||||
const events = []
|
||||
const remote = makeRemoteSentry((detection) => {
|
||||
const s = detection.confirm()
|
||||
if (s.type === 'send') {
|
||||
// remote plays sz: send the source file, then finish
|
||||
driveRemoteSend(s, srcFile).catch((e) => fail(`remote send error: ${e}`))
|
||||
}
|
||||
}, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0))
|
||||
|
||||
engine.attachZmodem(sessionId, {
|
||||
broadcast: (channel, payload) => events.push({ channel, payload }),
|
||||
toTerminal: () => {},
|
||||
writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0)
|
||||
})
|
||||
|
||||
// remote sz initiates with ZRQINIT
|
||||
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
||||
|
||||
// wait for the receive offer
|
||||
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10)
|
||||
const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload
|
||||
if (!offer) fail('download: no ZMODEM_OFFER received')
|
||||
if (offer.mode !== 'receive') fail(`download: expected receive offer, got ${offer.mode}`)
|
||||
console.log(`[download] offer received (mode=receive) id=${offer.id}`)
|
||||
|
||||
engine.respondZmodem({ id: offer.id, cancelled: false, dir: outDir })
|
||||
|
||||
// wait for done
|
||||
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10)
|
||||
const done = events.find((e) => e.channel === 'zmodem:done')?.payload
|
||||
if (!done) fail('download: no ZMODEM_DONE received')
|
||||
if (!done.ok) fail(`download: expected ok, got ${JSON.stringify(done)}`)
|
||||
|
||||
// assert progress events flowed
|
||||
const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
|
||||
if (prog.length === 0) fail('download: no TRANSFER_PROGRESS emitted')
|
||||
if (prog[0].kind !== 'zmodem-download') fail(`download: expected zmodem-download kind, got ${prog[0].kind}`)
|
||||
const finalProg = prog[prog.length - 1]
|
||||
if (finalProg.state !== 'done') fail(`download: last progress not done (${finalProg.state})`)
|
||||
|
||||
// assert content
|
||||
const got = readFileSync(join(outDir, 'transferred.bin'))
|
||||
if (got.length !== payload.length || !got.equals(payload)) {
|
||||
fail(`download: content mismatch (len ${got.length} vs ${payload.length})`)
|
||||
}
|
||||
console.log(`[download] file received: ${got.length} bytes, content matches`)
|
||||
console.log(`[download] progress events: ${prog.length} (state->done), done(ok)=true`)
|
||||
|
||||
rmSync(srcDir, { recursive: true, force: true })
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---- SCENARIO 2: upload (our engine -> remote `rz`) ----------------------------
|
||||
async function scenarioUpload() {
|
||||
console.log('\n=== 场景 2: 本地上传 + 远端 rz (upload) ===')
|
||||
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
||||
const srcDir = mkdtempSync(join(__dirname, '.zm-src-up-'))
|
||||
const srcFile = join(srcDir, 'sendme.bin')
|
||||
const payload = randomBytes(150_000)
|
||||
writeFileSync(srcFile, payload)
|
||||
const outDir = mkdtempSync(join(__dirname, '.zm-out-up-'))
|
||||
|
||||
const events = []
|
||||
let remoteGot = null
|
||||
const remote = makeRemoteSentry((detection, self) => {
|
||||
const s = detection.confirm()
|
||||
if (s.type === 'receive') {
|
||||
// remote plays rz: accept the offered file, spool to disk.
|
||||
s.on('offer', (offer) => {
|
||||
const name = basename(offer.get_details().name)
|
||||
const chunks = []
|
||||
offer.on('input', (p) => chunks.push(Buffer.from(p)))
|
||||
offer
|
||||
.accept()
|
||||
.then(async () => {
|
||||
const data = Buffer.concat(chunks)
|
||||
writeFileSync(join(outDir, name), data)
|
||||
remoteGot = data
|
||||
})
|
||||
.catch((e) => fail(`remote receive error: ${e}`))
|
||||
})
|
||||
// start() once; zmodem.js re-sends ZRINIT itself after each accepted file.
|
||||
s.start().catch((e) => fail(`remote receive start error: ${e}`))
|
||||
}
|
||||
}, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0))
|
||||
|
||||
engine.attachZmodem(sessionId, {
|
||||
broadcast: (channel, payload) => events.push({ channel, payload }),
|
||||
toTerminal: () => {},
|
||||
writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0)
|
||||
})
|
||||
|
||||
// remote rz receiver initiates: we feed it ZRQINIT so it detects "receive",
|
||||
// confirms and start()s, which sends ZRINIT toward our engine.
|
||||
remote.sentry.consume(Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
||||
|
||||
// engine should detect send and offer
|
||||
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10)
|
||||
const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload
|
||||
if (!offer) fail('upload: no ZMODEM_OFFER received')
|
||||
if (offer.mode !== 'send') fail(`upload: expected send offer, got ${offer.mode}`)
|
||||
console.log(`[upload] offer received (mode=send) id=${offer.id}`)
|
||||
|
||||
engine.respondZmodem({ id: offer.id, cancelled: false, paths: [srcFile] })
|
||||
|
||||
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10)
|
||||
const done = events.find((e) => e.channel === 'zmodem:done')?.payload
|
||||
if (!done) fail('upload: no ZMODEM_DONE received')
|
||||
if (!done.ok) fail(`upload: expected ok, got ${JSON.stringify(done)}`)
|
||||
|
||||
const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
|
||||
if (prog.length === 0) fail('upload: no TRANSFER_PROGRESS emitted')
|
||||
if (prog[0].kind !== 'zmodem-upload') fail(`upload: expected zmodem-upload kind, got ${prog[0].kind}`)
|
||||
|
||||
if (!remoteGot) fail('upload: remote never received the file data')
|
||||
if (remoteGot.length !== payload.length || !payload.equals(remoteGot)) {
|
||||
fail(`upload: content mismatch (remote ${remoteGot.length} vs src ${payload.length})`)
|
||||
}
|
||||
console.log(`[upload] remote received ${remoteGot.length} bytes, content matches`)
|
||||
console.log(`[upload] progress events: ${prog.length}, done(ok)=true`)
|
||||
|
||||
rmSync(srcDir, { recursive: true, force: true })
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---- run ----------------------------------------------------------------------
|
||||
const timer = setTimeout(() => fail('zmodem-e2e timed out'), 30000)
|
||||
await scenarioDownload()
|
||||
await scenarioUpload()
|
||||
clearTimeout(timer)
|
||||
console.log('\n[zmodem-e2e] ALL CHECKS PASSED')
|
||||
process.exit(0)
|
||||
Reference in new issue
Block a user