New localPathGrants.ts: an in-memory registry of paths the user picked in a native dialog. Every check resolves realpath + stat at grant and use time; Windows case folding; missing files, directories-as-files, devices and symlinked parents can never pass. SFTP upload/download and zmodem send/ receive now refuse renderer-supplied local paths that were never granted, returning the resolved path so callers never re-traverse a symlink. keyPath is validated as a regular file <= 1MB before reading (a device node would have blocked the UI thread forever). Tests inject a stub policy via setLocalPathPolicy; production always defaults to the real registry. Also: webPreferences now explicitly pins contextIsolation/nodeIntegration/ webSecurity instead of relying on defaults. New offline test tests/local-path-grants.mjs (37 assertions incl. symlink escape); offline suite grows to 13. i18n: 6 main.sftp/main.key error keys in 4 languages.
594 lines
24 KiB
JavaScript
594 lines
24 KiB
JavaScript
/**
|
|
* 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).
|
|
* 场景 3 (throttle): slow peer, 2KB every 3ms -> the running progress
|
|
* broadcasts must stay under the 150ms floor.
|
|
* 场景 4 (sink err): save dir does not exist -> createWriteStream errors, the
|
|
* engine must finalize at once (not at the 90s stall) and
|
|
* release the terminal data plane.
|
|
* 场景 5 (detach): session killed with an offer pending / a transfer in
|
|
* flight -> detachZmodem must emit ZMODEM_DONE + a terminal
|
|
* progress event, exactly once. Also asserts that cancelling
|
|
* a confirmed session reports ok=false, not a fake success
|
|
* (abort() fires 'session_end' synchronously).
|
|
*
|
|
* 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)
|
|
}
|
|
|
|
// Temp dirs the scenarios create under tests/. They are removed on the way out,
|
|
// including when fail() exits the process mid-scenario — hence the exit hook
|
|
// next to the try/finally at the bottom.
|
|
const tempDirs = []
|
|
const mkTemp = (prefix) => {
|
|
const dir = mkdtempSync(join(__dirname, prefix))
|
|
tempDirs.push(dir)
|
|
return dir
|
|
}
|
|
const cleanupTempDirs = () => {
|
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
process.on('exit', cleanupTempDirs)
|
|
|
|
// ---- 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)
|
|
|
|
/**
|
|
* Local-path admission for the engine. In the app the default policy only
|
|
* accepts paths the user granted through a native dialog (localPathGrants.ts),
|
|
* and this harness has no dialog to grant from — so the scenarios supply their
|
|
* own double. It is deliberately permissive (and deliberately NOT a re-export
|
|
* of the real policy, which is what keeps these scenarios about transfer
|
|
* mechanics rather than admission rules).
|
|
*/
|
|
engine.setLocalPathPolicy({
|
|
readSource: (p) => (typeof p === 'string' ? p : null),
|
|
readDirectory: (d) => (typeof d === 'string' && d !== '' ? d : null),
|
|
writeTarget: (dir, name) =>
|
|
typeof dir === 'string' && dir !== '' && typeof name === 'string' && name !== ''
|
|
? join(dir, name)
|
|
: null
|
|
})
|
|
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* Same as driveRemoteSend, but paced: `chunkSize` bytes every `delayMs`. Keeps
|
|
* the transfer running long enough for the progress throttle to be measurable
|
|
* (zmodem.js caps a subpacket at 8192 bytes, so an unpaced transfer is over in
|
|
* a few milliseconds and produces almost no events either way).
|
|
*/
|
|
async function driveRemoteSendPaced(session, filePath, chunkSize, delayMs) {
|
|
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')
|
|
let off = 0
|
|
while (off < data.length) {
|
|
const end = Math.min(off + chunkSize, data.length)
|
|
const slice = new Uint8Array(data.buffer, data.byteOffset + off, end - off)
|
|
if (end < data.length) {
|
|
xfer.send(slice)
|
|
await sleep(delayMs)
|
|
} else {
|
|
await xfer.end(slice)
|
|
}
|
|
off = end
|
|
}
|
|
await session.close()
|
|
}
|
|
|
|
/** Wait for the first event on `channel` (ms budget); returns its payload. */
|
|
async function waitForEvent(events, channel, budgetMs = 5000) {
|
|
const deadline = Date.now() + budgetMs
|
|
for (;;) {
|
|
const hit = events.find((e) => e.channel === channel)
|
|
if (hit) return hit.payload
|
|
if (Date.now() >= deadline) return undefined
|
|
await sleep(10)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Feed octets to the peer sentry. The peer's zsession throws "peer_aborted"
|
|
* once our engine writes its abort CAN, which is the expected outcome in the
|
|
* scenarios where the engine deliberately kills the transfer.
|
|
*/
|
|
function safeConsume(sentry, data) {
|
|
try {
|
|
sentry.consume(data)
|
|
} catch {
|
|
// peer aborted by our own CAN
|
|
}
|
|
}
|
|
|
|
/** Attach our engine to a fake ssh session, collecting every broadcast. */
|
|
function attachCollecting(sessionId, writeStream) {
|
|
const events = []
|
|
engine.attachZmodem(sessionId, {
|
|
broadcast: (channel, payload) => events.push({ channel, payload }),
|
|
toTerminal: () => {},
|
|
writeStream
|
|
})
|
|
return events
|
|
}
|
|
|
|
const progressOf = (events) =>
|
|
events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
|
|
const doneOf = (events) => events.filter((e) => e.channel === 'zmodem:done').map((e) => e.payload)
|
|
|
|
/** 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 = mkTemp('.zm-src-dl-')
|
|
const srcFile = join(srcDir, 'transferred.bin')
|
|
const payload = randomBytes(200_000)
|
|
writeFileSync(srcFile, payload)
|
|
const outDir = mkTemp('.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`)
|
|
}
|
|
|
|
// ---- 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 = mkTemp('.zm-src-up-')
|
|
const srcFile = join(srcDir, 'sendme.bin')
|
|
const payload = randomBytes(150_000)
|
|
writeFileSync(srcFile, payload)
|
|
const outDir = mkTemp('.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`)
|
|
}
|
|
|
|
// ---- SCENARIO 3: progress throttle -------------------------------------------
|
|
async function scenarioThrottle() {
|
|
console.log('\n=== 场景 3: 进度节流 (download, 慢速对端) ===')
|
|
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
|
const srcDir = mkTemp('.zm-src-thr-')
|
|
const srcFile = join(srcDir, 'paced.bin')
|
|
const payload = randomBytes(300_000)
|
|
writeFileSync(srcFile, payload)
|
|
const outDir = mkTemp('.zm-out-thr-')
|
|
|
|
const remote = makeRemoteSentry(
|
|
(detection) => {
|
|
const s = detection.confirm()
|
|
if (s.type === 'send') {
|
|
// 2KB per tick => ~150 on_input calls: an unthrottled engine turns
|
|
// every one of them into a broadcast.
|
|
driveRemoteSendPaced(s, srcFile, 2048, 3).catch((e) => fail(`remote send error: ${e}`))
|
|
}
|
|
},
|
|
(buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0)
|
|
)
|
|
|
|
const events = attachCollecting(sessionId, (id, data) =>
|
|
setTimeout(() => safeConsume(remote.sentry, data), 0)
|
|
)
|
|
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
|
|
|
const offer = await waitForEvent(events, 'zmodem:offer')
|
|
if (!offer) fail('throttle: no ZMODEM_OFFER received')
|
|
|
|
const t0 = Date.now()
|
|
engine.respondZmodem({ id: offer.id, cancelled: false, dir: outDir })
|
|
const done = await waitForEvent(events, 'zmodem:done')
|
|
const elapsed = Date.now() - t0
|
|
if (!done) fail('throttle: no ZMODEM_DONE received')
|
|
if (!done.ok) fail(`throttle: expected ok, got ${JSON.stringify(done)}`)
|
|
|
|
const got = readFileSync(join(outDir, 'paced.bin'))
|
|
if (got.length !== payload.length || !got.equals(payload)) {
|
|
fail(`throttle: content mismatch (len ${got.length} vs ${payload.length})`)
|
|
}
|
|
|
|
const prog = progressOf(events)
|
|
// 150ms floor => at most one event per window, plus the deliberately forced
|
|
// file-boundary/final events (4) and slack for timer jitter. Without the
|
|
// throttle this is one event per subpacket (~150), so the gap is wide.
|
|
const bound = Math.ceil(elapsed / 150) + 8
|
|
if (prog.length > bound) {
|
|
fail(`throttle: ${prog.length} progress events in ${elapsed}ms exceeds bound ${bound}`)
|
|
}
|
|
if (prog.length < 2) fail(`throttle: only ${prog.length} progress events, throttle too aggressive`)
|
|
if (prog[prog.length - 1].state !== 'done') {
|
|
fail(`throttle: last progress state ${prog[prog.length - 1].state}, expected done`)
|
|
}
|
|
console.log(`[throttle] ${prog.length} progress events in ${elapsed}ms (bound ${bound}), content matches`)
|
|
}
|
|
|
|
// ---- SCENARIO 4: dead write sink finalizes at once ---------------------------
|
|
async function scenarioWriteError() {
|
|
console.log('\n=== 场景 4: 接收落盘失败 (目录不存在) -> 立即终结 ===')
|
|
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
|
const srcDir = mkTemp('.zm-src-err-')
|
|
const srcFile = join(srcDir, 'fail.bin')
|
|
writeFileSync(srcFile, randomBytes(120_000))
|
|
const outDir = mkTemp('.zm-out-err-')
|
|
// Never created: createWriteStream() emits ENOENT, the path a full disk or a
|
|
// revoked permission would take.
|
|
const badDir = join(outDir, 'no-such-dir')
|
|
|
|
const remote = makeRemoteSentry(
|
|
(detection) => {
|
|
const s = detection.confirm()
|
|
if (s.type === 'send') {
|
|
// Paced on purpose: an unpaced in-process transfer finishes before the
|
|
// fs.open ENOENT even surfaces, and the sink would only fail after the
|
|
// session had already ended successfully. The engine aborts as soon as
|
|
// the sink fails, so the peer dying here is the expected outcome, not a
|
|
// test failure.
|
|
driveRemoteSendPaced(s, srcFile, 4096, 5).catch(() => {})
|
|
}
|
|
},
|
|
(buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0)
|
|
)
|
|
|
|
const events = attachCollecting(sessionId, (id, data) =>
|
|
setTimeout(() => safeConsume(remote.sentry, data), 0)
|
|
)
|
|
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
|
|
|
const offer = await waitForEvent(events, 'zmodem:offer')
|
|
if (!offer) fail('write-error: no ZMODEM_OFFER received')
|
|
|
|
const t0 = Date.now()
|
|
engine.respondZmodem({ id: offer.id, cancelled: false, dir: badDir })
|
|
// 2s budget: the point is that this does NOT wait for the 90s stall watchdog.
|
|
const done = await waitForEvent(events, 'zmodem:done', 2000)
|
|
if (!done) fail('write-error: no ZMODEM_DONE within 2s (engine stuck active?)')
|
|
if (done.ok) {
|
|
fail(`write-error: expected ok=false, got ${JSON.stringify(done)} — sink error lost the race`)
|
|
}
|
|
if (engine.isZmodemActive(sessionId)) {
|
|
fail('write-error: engine still active — terminal data plane stays suppressed')
|
|
}
|
|
const prog = progressOf(events)
|
|
const last = prog[prog.length - 1]
|
|
if (last.state !== 'error') fail(`write-error: last progress state ${last.state}, expected error`)
|
|
console.log(`[write-error] done(ok=false) after ${Date.now() - t0}ms, state=error, engine inactive`)
|
|
}
|
|
|
|
// ---- SCENARIO 5: detach (session killed) emits the terminal events ------------
|
|
async function scenarioDetach() {
|
|
console.log('\n=== 场景 5: 会话被 kill -> detach 补发终结事件 ===')
|
|
|
|
// (a) offer still pending: the renderer's modal is up and nothing will answer
|
|
{
|
|
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
|
const events = attachCollecting(sessionId, () => {})
|
|
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
|
const offer = await waitForEvent(events, 'zmodem:offer')
|
|
if (!offer) fail('detach(pending): no ZMODEM_OFFER received')
|
|
|
|
engine.detachZmodem(sessionId)
|
|
const done = doneOf(events)
|
|
if (done.length !== 1) fail(`detach(pending): expected 1 ZMODEM_DONE, got ${done.length}`)
|
|
if (done[0].ok) fail('detach(pending): expected ok=false')
|
|
if (engine.isZmodemActive(sessionId)) fail('detach(pending): engine still active')
|
|
const prog = progressOf(events)
|
|
if (prog[prog.length - 1].state !== 'cancelled') {
|
|
fail(`detach(pending): last progress state ${prog[prog.length - 1].state}, expected cancelled`)
|
|
}
|
|
// Idempotent: the engine is already gone, a second detach emits nothing.
|
|
engine.detachZmodem(sessionId)
|
|
if (doneOf(events).length !== 1) fail('detach(pending): ZMODEM_DONE emitted twice')
|
|
console.log('[detach] pending offer -> done(ok=false) + cancelled progress, idempotent')
|
|
}
|
|
|
|
// (b) transfer in flight
|
|
{
|
|
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
|
const srcDir = mkTemp('.zm-src-det-')
|
|
const srcFile = join(srcDir, 'killed.bin')
|
|
writeFileSync(srcFile, randomBytes(200_000))
|
|
const outDir = mkTemp('.zm-out-det-')
|
|
|
|
let detached = false
|
|
const remote = makeRemoteSentry(
|
|
(detection) => {
|
|
const s = detection.confirm()
|
|
if (s.type === 'send') {
|
|
// Killed mid-flight on purpose: a dying peer is expected here.
|
|
driveRemoteSendPaced(s, srcFile, 2048, 3).catch(() => {})
|
|
}
|
|
},
|
|
(buf) => {
|
|
if (detached) return
|
|
setTimeout(() => engine.feedZmodem(sessionId, buf), 0)
|
|
}
|
|
)
|
|
|
|
const events = attachCollecting(sessionId, (id, data) => {
|
|
if (detached) return
|
|
setTimeout(() => remote.sentry.consume(data), 0)
|
|
})
|
|
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
|
const offer = await waitForEvent(events, 'zmodem:offer')
|
|
if (!offer) fail('detach(live): no ZMODEM_OFFER received')
|
|
|
|
engine.respondZmodem({ id: offer.id, cancelled: false, dir: outDir })
|
|
if (!engine.isZmodemActive(sessionId)) fail('detach(live): engine not active after respond')
|
|
|
|
const mark = events.length
|
|
detached = true
|
|
engine.detachZmodem(sessionId)
|
|
await sleep(300)
|
|
|
|
const done = doneOf(events)
|
|
if (done.length !== 1) fail(`detach(live): expected 1 ZMODEM_DONE, got ${done.length}`)
|
|
if (done[0].ok) fail('detach(live): expected ok=false')
|
|
if (engine.isZmodemActive(sessionId)) fail('detach(live): engine still active')
|
|
|
|
const after = progressOf(events.slice(mark))
|
|
if (after.length === 0) fail('detach(live): no terminal progress emitted')
|
|
if (after[after.length - 1].state !== 'cancelled') {
|
|
fail(`detach(live): last progress state ${after[after.length - 1].state}, expected cancelled`)
|
|
}
|
|
if (after.some((p) => p.state === 'running')) {
|
|
fail('detach(live): progress still running after detach')
|
|
}
|
|
engine.detachZmodem(sessionId)
|
|
if (doneOf(events).length !== 1) fail('detach(live): ZMODEM_DONE emitted twice')
|
|
console.log('[detach] live transfer -> done(ok=false) + cancelled progress, exactly once')
|
|
}
|
|
|
|
// (c) user cancels a *confirmed* session (no files chosen). abortSession()
|
|
// aborts before it finalizes, and the abort fires 'session_end' synchronously:
|
|
// without the engine.ending guard that re-entered finalize(ok=true) and the
|
|
// cancel was reported as a successful transfer.
|
|
{
|
|
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
|
const remote = makeRemoteSentry(
|
|
(detection) => {
|
|
const s = detection.confirm()
|
|
if (s.type === 'receive') s.start().catch(() => {})
|
|
},
|
|
(buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0)
|
|
)
|
|
const events = attachCollecting(sessionId, (id, data) =>
|
|
setTimeout(() => safeConsume(remote.sentry, data), 0)
|
|
)
|
|
// Remote rz initiates: it detects "receive" from ZRQINIT, confirms and
|
|
// start()s, which sends ZRINIT toward our engine.
|
|
remote.sentry.consume(Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
|
|
|
|
const offer = await waitForEvent(events, 'zmodem:offer')
|
|
if (!offer) fail('detach(cancel): no ZMODEM_OFFER received')
|
|
if (offer.mode !== 'send') fail(`detach(cancel): expected send offer, got ${offer.mode}`)
|
|
|
|
engine.respondZmodem({ id: offer.id, cancelled: false, paths: [] })
|
|
const done = doneOf(events)
|
|
if (done.length !== 1) fail(`detach(cancel): expected 1 ZMODEM_DONE, got ${done.length}`)
|
|
if (done[0].ok) fail('detach(cancel): a cancelled transfer was reported as success')
|
|
if (engine.isZmodemActive(sessionId)) fail('detach(cancel): engine still active')
|
|
const prog = progressOf(events)
|
|
if (prog[prog.length - 1].state !== 'cancelled') {
|
|
fail(`detach(cancel): last progress state ${prog[prog.length - 1].state}, expected cancelled`)
|
|
}
|
|
console.log('[detach] confirmed session cancelled -> done(ok=false), no fake success')
|
|
}
|
|
}
|
|
|
|
// ---- run ----------------------------------------------------------------------
|
|
const timer = setTimeout(() => fail('zmodem-e2e timed out'), 30000)
|
|
try {
|
|
await scenarioDownload()
|
|
await scenarioUpload()
|
|
await scenarioThrottle()
|
|
await scenarioWriteError()
|
|
await scenarioDetach()
|
|
} finally {
|
|
clearTimeout(timer)
|
|
cleanupTempDirs()
|
|
}
|
|
console.log('\n[zmodem-e2e] ALL CHECKS PASSED')
|
|
process.exit(0) |