test: rebuildable bundles, npm test entry point

- esbuild alias fixed in the session/sysinfo/sftp test commands (they
  could not build at all), .sftp-svc.mjs build documented, real
  connection-stability assertions
- tests/build-bundles.cjs builds every bundle fresh; npm test runs the
  seven offline suites; esbuild pinned in devDependencies
- remove the assertion-less .exact-inline.mjs; ignore/clean test temp dirs
This commit is contained in:
Bill committed 2026-09-20 20:27:06 +08:00
1 parent 24f7e7c52a
commit cca4ba029a
12 files changed
+108 -92

No files matched your search

+3
View File
@@ -33,6 +33,9 @@ release/
# stray local test artifacts
.min-test.bin
.min2-test.bin
.exact-test.bin
tests/.zm-src-*/
tests/.zm-out-*/
# release notes drafts (consumed by scripts/release.cjs + sync-changelog.cjs)
RELEASE_NOTES*.md
+3 -1
View File
@@ -7,6 +7,7 @@
"": {
"name": "open-terminal",
"version": "1.0.13",
"license": "MIT",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"@xterm/addon-fit": "^0.11.0",
@@ -32,6 +33,7 @@
"electron": "^44.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"esbuild": "^0.25.12",
"typescript": "~5.9.0",
"vite": "^7.0.0"
}
@@ -4291,7 +4293,7 @@
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
+2
View File
@@ -12,6 +12,7 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/.hl-split-smoke.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
"dist": "electron-vite build && electron-builder --win msi nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
},
@@ -40,6 +41,7 @@
"electron": "^44.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"esbuild": "^0.25.12",
"typescript": "~5.9.0",
"vite": "^7.0.0"
}
-66
View File
@@ -1,66 +0,0 @@
// Decisive test: exact sftp.ts upload code, inline (no esbuild bundle).
import { createRequire } from 'module'
import { promises as fsp } from 'fs'
const require_ = createRequire(import.meta.url)
const ssh2 = require_('ssh2')
const sessionLayer = require_('D:/Coding/Terminal/tests/.session-e2e.cjs')
let clientProvider
function p(fn) {
return new Promise((resolve, reject) => {
fn((err, res) => (err ? reject(err) : resolve(res)))
})
}
sessionLayer.configureSessionRuntime({
broadcast: () => {},
getConnection: () => ({
id: 'jd', name: 't', host: process.env.JD_HOST, port: 22, username: process.env.JD_USER,
auth: 'password', askPasswordAtConnect: false, askPassphraseAtConnect: false,
keepaliveIntervalSec: 30, createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}),
getSecret: () => process.env.JD_PASS,
touch: () => {},
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
promptHostKey: () => { throw new Error('x') }
})
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
const client = sessionLayer.getSshClient(id)
// --- exact sftp.ts upload pattern ---
const transfer = { kind: 'upload', cancelled: false }
const emit = (e) => console.log('[evt]', e.state, e.file, e.bytes + '/' + e.totalBytes, e.error ?? '')
const local = './.exact-test.bin'
const content = Buffer.alloc(256 * 1024, 2)
await fsp.writeFile(local, content)
const remote = '/root/ot_exact.bin'
await new Promise((resolve, reject) => {
client.sftp((err, sftp) => (err != null ? reject(err) : resolve(sftp)))
})
const sftp = await new Promise((resolve, reject) => {
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
})
console.log('[exact] sftp channel open, calling fastPut')
await new Promise((resolve, reject) => {
sftp.fastPut(local, remote, {
concurrency: 4,
step: (totalTransferred, _chunk, total) => {
console.log('[exact] step', totalTransferred, '/', total)
if (transfer.cancelled) {
reject(new Error('已取消'))
return
}
}
}, (err) => (err ? reject(err) : resolve()))
})
const st = await new Promise((resolve, reject) => {
sftp.stat(remote, (err, s) => (err ? reject(err) : resolve(s)))
})
console.log('[exact] OK, remote size:', st.size, 'expected:', content.length)
await new Promise((resolve, reject) => sftp.unlink(remote, (e) => (e ? reject(e) : resolve())))
await fsp.rm(local, { force: true })
sessionLayer.killPty(id)
process.exit(0)
+43
View File
@@ -0,0 +1,43 @@
/* Rebuild every esbuild bundle the tests load, so a test run can never exercise
a stale bundle. `npm test` calls this first; run it standalone when you only
want fresh bundles.
The aliases live here (and nowhere else) so every bundle resolves `electron`
and `@shared` the same way. A bundle built without
`--alias:@shared=./src/shared` does not even compile: pty.ts reaches
@shared/theme through settingsStore.ts -> windowChrome.ts.
Usage: node tests/build-bundles.cjs */
const path = require('node:path')
const esbuild = require('esbuild')
const ROOT = path.join(__dirname, '..')
const BUNDLES = [
// Real session layer: pty.ts also re-exports the ssh + sysinfo engines.
{ entry: 'src/main/pty.ts', out: 'tests/.session-e2e.cjs', external: ['@lydell/node-pty', 'ssh2'] },
// ESM (`.mjs`): tests/sftp-*.mjs load it with `await import()`.
{ entry: 'src/main/sftp.ts', out: 'tests/.sftp-svc.mjs', format: 'esm', external: ['ssh2'] },
{ entry: 'src/main/commands.ts', out: 'tests/.commands-store.cjs' },
{ entry: 'src/main/settingsStore.ts', out: 'tests/.settings-store.cjs' },
// zmodem.js stays bundled (NOT external) — the test drives a second in-process
// Sentry from the same library.
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
// The smoke test imports the renderer engine (.ts), so it needs bundling too.
{ entry: 'tests/hl-split-smoke.mjs', out: 'tests/.hl-split-smoke.cjs' }
]
for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) {
esbuild.buildSync({
absWorkingDir: ROOT,
entryPoints: [path.join(ROOT, entry)],
outfile: path.join(ROOT, out),
bundle: true,
platform: 'node',
format,
external,
alias: { electron: './tests/electron-stub.cjs', '@shared': './src/shared' },
logLevel: 'warning'
})
console.log(`built ${out}`)
}
+5 -1
View File
@@ -10,7 +10,7 @@
* --alias:@shared=./src/shared
* Run: node tests/commands-store.mjs (must exit 0)
*/
import { mkdtempSync, readFileSync, writeFileSync } from 'fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'module'
@@ -227,5 +227,9 @@ const expected =
'partial-tail'
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
// The store wrote into a temp userData dir; drop it so repeated runs do not
// litter %TEMP%.
rmSync(userData, { recursive: true, force: true })
console.log('\n[commands] ALL CHECKS PASSED')
process.exit(0)
+5 -1
View File
@@ -14,7 +14,7 @@
* --alias:@shared=./src/shared
* node tests/settings-store.mjs
*/
import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
@@ -111,5 +111,9 @@ console.log('[warnings log]')
ok(readFileSync(file, 'utf8').length === size, 'identical warnings are not appended twice')
}
// The store wrote settings into a temp userData dir; drop it so repeated runs do
// not litter %TEMP%.
rmSync(userData, { recursive: true, force: true })
console.log(failed === 0 ? '\n[settings] ALL CHECKS PASSED' : `\n[settings] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
+4 -2
View File
@@ -1,6 +1,7 @@
// Real-server chmod/chown + mode-string verification (M4). Credentials from env:
// JD_HOST / JD_USER / JD_PASS
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
// Prereq bundles: `node tests/build-bundles.cjs` builds both of them —
// tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
const sessionLayer = require_('./.session-e2e.cjs')
@@ -71,7 +72,8 @@ try {
console.log('[chmod] cleanup ok')
const exits = events.filter((e) => e.channel === 'pty:exit')
console.log('[chmod] unexpected exits:', exits.length)
if (exits.length) fail(`session exited unexpectedly during the chmod test (${exits.length}x)`)
console.log('[chmod] no unexpected session exits')
sessionLayer.killPty(id)
console.log('[chmod] ALL CHECKS PASSED')
+4 -2
View File
@@ -1,6 +1,7 @@
// Real-server SFTP verification (M4). Credentials from env (never hardcoded):
// JD_HOST / JD_USER / JD_PASS
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.cjs (sftp service)
// Prereq bundles: `node tests/build-bundles.cjs` builds both of them —
// tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
import { createRequire } from 'module'
import { randomUUID } from 'crypto'
import { promises as fsp } from 'fs'
@@ -100,7 +101,8 @@ try {
console.log('[sftp] recursive delete ok')
const exits = events.filter((e) => e.channel === 'pty:exit')
console.log('[sftp] unexpected exits:', exits.length)
if (exits.length) fail(`session exited unexpectedly during the transfer (${exits.length}x) — see the debug log above`)
console.log('[sftp] no unexpected session exits')
await fsp.rm(localFile, { force: true })
await fsp.rm(dlDir, { recursive: true, force: true })
+7 -3
View File
@@ -4,9 +4,13 @@
* in-process ssh2.Server — the same openSession/write/resize/kill pipeline the
* app uses, minus the IPC + renderer shells.
*
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* Pre-req: node tests/build-bundles.cjs (or, by hand:)
* npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* --alias:@shared=./src/shared
* (--alias:@shared=./src/shared is required: pty.ts reaches @shared/theme
* through settingsStore.ts -> windowChrome.ts)
* Run: node tests/ssh-session-e2e.mjs (must exit 0)
*/
+7 -3
View File
@@ -5,9 +5,13 @@
* broadcast, growing samples (usage + net rates), and that stopPolling halts
* the stream.
*
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* Pre-req: node tests/build-bundles.cjs (or, by hand:)
* npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* --alias:@shared=./src/shared
* (--alias:@shared=./src/shared is required: pty.ts reaches @shared/theme
* through settingsStore.ts -> windowChrome.ts)
* Run: node tests/sysinfo-e2e.mjs (must exit 0)
*/
+25 -13
View File
@@ -38,6 +38,20 @@ const 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)) {
@@ -117,11 +131,11 @@ function makeRemoteSentry(remoteOnDetect, routeOut) {
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 srcDir = mkTemp('.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 outDir = mkTemp('.zm-out-dl-')
const events = []
const remote = makeRemoteSentry((detection) => {
@@ -170,20 +184,17 @@ async function scenarioDownload() {
}
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 srcDir = mkTemp('.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 outDir = mkTemp('.zm-out-up-')
const events = []
let remoteGot = null
@@ -243,15 +254,16 @@ async function scenarioUpload() {
}
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)
try {
await scenarioDownload()
await scenarioUpload()
} finally {
clearTimeout(timer)
cleanupTempDirs()
}
console.log('\n[zmodem-e2e] ALL CHECKS PASSED')
process.exit(0)