From cca4ba029a3a7765bc15b4570652f8285c48cf61 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 20 Sep 2026 20:27:06 +0800 Subject: [PATCH] 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 --- .gitignore | 3 ++ package-lock.json | 4 ++- package.json | 2 ++ tests/.exact-inline.mjs | 66 --------------------------------------- tests/build-bundles.cjs | 43 +++++++++++++++++++++++++ tests/commands-store.mjs | 6 +++- tests/settings-store.mjs | 6 +++- tests/sftp-chmod.mjs | 6 ++-- tests/sftp-real.mjs | 6 ++-- tests/ssh-session-e2e.mjs | 10 ++++-- tests/sysinfo-e2e.mjs | 10 ++++-- tests/zmodem-e2e.mjs | 38 ++++++++++++++-------- 12 files changed, 108 insertions(+), 92 deletions(-) delete mode 100644 tests/.exact-inline.mjs create mode 100644 tests/build-bundles.cjs diff --git a/.gitignore b/.gitignore index c831f26..a4647fe 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package-lock.json b/package-lock.json index 2b5aa08..ae81374 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/package.json b/package.json index 1bcc8aa..1cf7612 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/tests/.exact-inline.mjs b/tests/.exact-inline.mjs deleted file mode 100644 index b4ce7df..0000000 --- a/tests/.exact-inline.mjs +++ /dev/null @@ -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) diff --git a/tests/build-bundles.cjs b/tests/build-bundles.cjs new file mode 100644 index 0000000..a1c670e --- /dev/null +++ b/tests/build-bundles.cjs @@ -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}`) +} diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs index 332d1dd..18333a7 100644 --- a/tests/commands-store.mjs +++ b/tests/commands-store.mjs @@ -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) \ No newline at end of file diff --git a/tests/settings-store.mjs b/tests/settings-store.mjs index 4a301dc..6dde9bd 100644 --- a/tests/settings-store.mjs +++ b/tests/settings-store.mjs @@ -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) diff --git a/tests/sftp-chmod.mjs b/tests/sftp-chmod.mjs index 0306b2c..34e698e 100644 --- a/tests/sftp-chmod.mjs +++ b/tests/sftp-chmod.mjs @@ -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') diff --git a/tests/sftp-real.mjs b/tests/sftp-real.mjs index 7e2b37d..7e36ec1 100644 --- a/tests/sftp-real.mjs +++ b/tests/sftp-real.mjs @@ -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 }) diff --git a/tests/ssh-session-e2e.mjs b/tests/ssh-session-e2e.mjs index dc56a46..19ac478 100644 --- a/tests/ssh-session-e2e.mjs +++ b/tests/ssh-session-e2e.mjs @@ -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) */ diff --git a/tests/sysinfo-e2e.mjs b/tests/sysinfo-e2e.mjs index 90927d0..2e8f174 100644 --- a/tests/sysinfo-e2e.mjs +++ b/tests/sysinfo-e2e.mjs @@ -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) */ diff --git a/tests/zmodem-e2e.mjs b/tests/zmodem-e2e.mjs index 145232d..ac20653 100644 --- a/tests/zmodem-e2e.mjs +++ b/tests/zmodem-e2e.mjs @@ -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) \ No newline at end of file