/** * Tests for npm/install.js and npm/utils.js * Tests platform detection, download patterns, extraction, and CLI launcher flow */ const os = require('os') const path = require('path') const fs = require('fs') const tar = require('tar') const { isWindows7OrEarlier, isMacOS10, isLinuxLegacy, sanitizeVersion, sanitizeFilename, getElectermExePath, isElectermExtracted, _packageRoot, _extractDir } = require('../../npm/install') const { httpGet, extractTarGz, download, phin, applyProxy, formatBytes } = require('../../npm/utils') const plat = os.platform() // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- let passed = 0 let failed = 0 const asyncQueue = [] function test (name, fn) { try { fn() console.log(` ✓ ${name}`) passed++ } catch (e) { console.error(` ✗ ${name}`) console.error(` Error: ${e.message}`) failed++ } } function testAsync (name, fn) { asyncQueue.push(async () => { try { await fn() console.log(` ✓ ${name}`) passed++ } catch (e) { console.error(` ✗ ${name}`) console.error(` Error: ${e.message}`) failed++ } }) } function expect (actual) { return { toBe (expected) { if (actual !== expected) { throw new Error(`Expected ${expected} but got ${actual}`) } }, toEqual (expected) { if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`) } }, toContain (expected) { if (!actual.includes(expected)) { throw new Error(`Expected ${actual} to contain ${expected}`) } }, toMatch (pattern) { if (!pattern.test(actual)) { throw new Error(`Expected ${actual} to match ${pattern}`) } }, toBeTruthy () { if (!actual) { throw new Error(`Expected ${actual} to be truthy`) } }, toBeFalsy () { if (actual) { throw new Error(`Expected ${actual} to be falsy`) } } } } function testThrows (fn, expectedMessage) { try { fn() throw new Error('Expected function to throw') } catch (e) { if (e.message === 'Expected function to throw') throw e if (expectedMessage && !e.message.includes(expectedMessage)) { throw new Error(`Expected error to include "${expectedMessage}" but got "${e.message}"`) } } } // --------------------------------------------------------------------------- // Download pattern helper (for testing) // --------------------------------------------------------------------------- function getDownloadPattern (platform, architecture, options = {}) { const { win7, mac10, linuxLegacy } = options if (platform === 'win32') { if (win7) { return { pattern: /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/, type: 'win7' } } else if (architecture === 'arm64') { return { pattern: /electerm-\d+\.\d+\.\d+-win-arm64\.tar\.gz$/, type: 'win-arm64' } } else { return { pattern: /electerm-\d+\.\d+\.\d+-win-x64\.tar\.gz$/, type: 'win-x64' } } } else if (platform === 'darwin') { if (mac10) { return { pattern: /mac10-x64\.dmg$/, type: 'mac10-x64' } } else if (architecture === 'arm64') { return { pattern: /mac-arm64\.dmg$/, type: 'mac-arm64' } } else { return { pattern: /mac-x64\.dmg$/, type: 'mac-x64' } } } else if (platform === 'linux') { const suffix = linuxLegacy ? '-legacy' : '' if (architecture === 'arm64' || architecture === 'aarch64') { return { pattern: new RegExp(`linux-arm64${suffix}\\.tar\\.gz$`), type: `linux-arm64${suffix}` } } else if (architecture === 'arm') { return { pattern: new RegExp(`linux-armv7l${suffix}\\.tar\\.gz$`), type: `linux-armv7l${suffix}` } } else if (architecture === 'loong64') { return { pattern: new RegExp(`linux-loong64${suffix}\\.tar\\.gz$`), type: `linux-loong64${suffix}` } } else { return { pattern: new RegExp(`linux-x64${suffix}\\.tar\\.gz$`), type: `linux-x64${suffix}` } } } return { pattern: null, type: 'unsupported' } } // ============================================================================= // Tests: Platform Detection // ============================================================================= console.log('\n=== Platform Detection Tests ===\n') test('isWindows7OrEarlier: returns false for non-Windows platforms', () => { expect(isWindows7OrEarlier('darwin', '21.0.0')).toBe(false) expect(isWindows7OrEarlier('linux', '5.4.0')).toBe(false) }) test('isWindows7OrEarlier: returns true for Windows 7 (NT 6.1)', () => { expect(isWindows7OrEarlier('win32', '6.1.7601')).toBe(true) }) test('isWindows7OrEarlier: returns true for Windows Vista (NT 6.0)', () => { expect(isWindows7OrEarlier('win32', '6.0.6000')).toBe(true) }) test('isWindows7OrEarlier: returns true for Windows XP (NT 5.1)', () => { expect(isWindows7OrEarlier('win32', '5.1.2600')).toBe(true) }) test('isWindows7OrEarlier: returns false for Windows 8 (NT 6.2)', () => { expect(isWindows7OrEarlier('win32', '6.2.9200')).toBe(false) }) test('isWindows7OrEarlier: returns false for Windows 10 (NT 10.0)', () => { expect(isWindows7OrEarlier('win32', '10.0.19041')).toBe(false) }) test('isWindows7OrEarlier: returns false for Windows 11 (NT 10.0)', () => { expect(isWindows7OrEarlier('win32', '10.0.22000')).toBe(false) }) test('isMacOS10: returns false for non-macOS platforms', () => { expect(isMacOS10('win32', '10.0.19041')).toBe(false) expect(isMacOS10('linux', '5.4.0')).toBe(false) }) test('isMacOS10: returns true for macOS 10.15 Catalina (Darwin 19.x)', () => { expect(isMacOS10('darwin', '19.6.0')).toBe(true) }) test('isMacOS10: returns true for macOS 10.14 Mojave (Darwin 18.x)', () => { expect(isMacOS10('darwin', '18.7.0')).toBe(true) }) test('isMacOS10: returns false for macOS 11 Big Sur (Darwin 20.x)', () => { expect(isMacOS10('darwin', '20.6.0')).toBe(false) }) test('isMacOS10: returns false for macOS 14 Sonoma (Darwin 23.x)', () => { expect(isMacOS10('darwin', '23.0.0')).toBe(false) }) test('isLinuxLegacy: returns false for non-Linux platforms', () => { expect(isLinuxLegacy('win32')).toBe(false) expect(isLinuxLegacy('darwin')).toBe(false) }) // ============================================================================= // Tests: Security Sanitization // ============================================================================= console.log('\n=== Security Sanitization Tests ===\n') test('sanitizeVersion: removes v prefix', () => { expect(sanitizeVersion('v1.2.3')).toBe('1.2.3') }) test('sanitizeVersion: trims whitespace', () => { expect(sanitizeVersion(' 1.2.3 ')).toBe('1.2.3') }) test('sanitizeVersion: passes valid semver', () => { expect(sanitizeVersion('1.2.3')).toBe('1.2.3') expect(sanitizeVersion('3.2.0')).toBe('3.2.0') }) test('sanitizeVersion: throws on invalid version', () => { testThrows(() => sanitizeVersion('1.2'), 'validation') testThrows(() => sanitizeVersion('abc'), 'validation') testThrows(() => sanitizeVersion('1.2.3.4'), 'validation') testThrows(() => sanitizeVersion('1.2.3-beta'), 'validation') }) test('sanitizeFilename: passes valid filenames', () => { expect(sanitizeFilename('electerm-3.2.0-linux-x64.tar.gz')).toBe('electerm-3.2.0-linux-x64.tar.gz') expect(sanitizeFilename('electerm-3.2.0-mac-x64.dmg')).toBe('electerm-3.2.0-mac-x64.dmg') }) test('sanitizeFilename: trims whitespace', () => { expect(sanitizeFilename(' test.tar.gz ')).toBe('test.tar.gz') }) test('sanitizeFilename: throws on invalid filenames', () => { testThrows(() => sanitizeFilename('../evil.sh'), 'validation') testThrows(() => sanitizeFilename('test; rm -rf /'), 'validation') testThrows(() => sanitizeFilename('test$(whoami).tar.gz'), 'validation') }) // ============================================================================= // Tests: Download Patterns // ============================================================================= console.log('\n=== Download Pattern Tests ===\n') const v = '3.2.0' const releaseFiles = [ `electerm-${v}-linux-arm64.tar.gz`, `electerm-${v}-linux-arm64-legacy.tar.gz`, `electerm-${v}-linux-armv7l.tar.gz`, `electerm-${v}-linux-armv7l-legacy.tar.gz`, `electerm-${v}-linux-loong64.tar.gz`, `electerm-${v}-linux-loong64-legacy.tar.gz`, `electerm-${v}-linux-x64.tar.gz`, `electerm-${v}-linux-x64-legacy.tar.gz`, `electerm-${v}-mac-arm64.dmg`, `electerm-${v}-mac-x64.dmg`, `electerm-${v}-mac10-x64.dmg`, `electerm-${v}-win-arm64.tar.gz`, `electerm-${v}-win-x64.tar.gz`, `electerm-${v}-win7.tar.gz` ] test('pattern: win-x64 matches exactly one file', () => { const { pattern } = getDownloadPattern('win32', 'x64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-win-x64.tar.gz`]) }) test('pattern: win-arm64 matches exactly one file', () => { const { pattern } = getDownloadPattern('win32', 'arm64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-win-arm64.tar.gz`]) }) test('pattern: win7 matches exactly one file', () => { const { pattern } = getDownloadPattern('win32', 'x64', { win7: true }) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-win7.tar.gz`]) }) test('pattern: mac-x64 matches exactly one file', () => { const { pattern } = getDownloadPattern('darwin', 'x64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-mac-x64.dmg`]) }) test('pattern: mac-arm64 matches exactly one file', () => { const { pattern } = getDownloadPattern('darwin', 'arm64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-mac-arm64.dmg`]) }) test('pattern: mac10-x64 matches exactly one file', () => { const { pattern } = getDownloadPattern('darwin', 'x64', { mac10: true }) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-mac10-x64.dmg`]) }) test('pattern: linux-x64 matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'x64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-x64.tar.gz`]) }) test('pattern: linux-x64-legacy matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'x64', { linuxLegacy: true }) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-x64-legacy.tar.gz`]) }) test('pattern: linux-arm64 matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'arm64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-arm64.tar.gz`]) }) test('pattern: linux-arm64-legacy matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'arm64', { linuxLegacy: true }) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-arm64-legacy.tar.gz`]) }) test('pattern: linux-armv7l matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'arm', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-armv7l.tar.gz`]) }) test('pattern: linux-armv7l-legacy matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'arm', { linuxLegacy: true }) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-armv7l-legacy.tar.gz`]) }) test('pattern: linux-loong64 matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'loong64', {}) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-loong64.tar.gz`]) }) test('pattern: linux-loong64-legacy matches exactly one file', () => { const { pattern } = getDownloadPattern('linux', 'loong64', { linuxLegacy: true }) const matches = releaseFiles.filter(f => pattern.test(f)) expect(matches).toEqual([`electerm-${v}-linux-loong64-legacy.tar.gz`]) }) test('pattern: unsupported platform returns null pattern', () => { const { pattern, type } = getDownloadPattern('freebsd', 'x64', {}) expect(pattern).toBe(null) expect(type).toBe('unsupported') }) // ============================================================================= // Tests: Extracted Binary Path // ============================================================================= console.log('\n=== Extracted Binary Path Tests ===\n') test('getElectermExePath: returns correct path for current platform', () => { const exePath = getElectermExePath() if (plat === 'win32') { expect(exePath).toBe(path.join(_extractDir, 'electerm.exe')) } else { expect(exePath).toBe(path.join(_extractDir, 'electerm')) } }) test('getElectermExePath: extractDir is inside packageRoot', () => { expect(_extractDir).toContain(_packageRoot) expect(_extractDir).toBe(path.join(_packageRoot, 'electerm')) }) test('isElectermExtracted: returns boolean', () => { const result = isElectermExtracted() expect(typeof result).toBe('boolean') }) // ============================================================================= // Tests: Utils - Proxy Support // ============================================================================= console.log('\n=== Utils Proxy Tests ===\n') test('applyProxy: returns original URL when no proxy configured', () => { // GITHUB_PROXY is empty by default in tests const result = applyProxy('https://github.com/test/file.tar.gz') expect(result).toBe('https://github.com/test/file.tar.gz') }) test('applyProxy: does not proxy non-GitHub URLs', () => { const result = applyProxy('https://example.com/file.tar.gz') expect(result).toBe('https://example.com/file.tar.gz') }) test('applyProxy: proxies GitHub URLs when GITHUB_PROXY is set', () => { // Test the logic directly since module caches GITHUB_PROXY at load time const proxy = 'https://electerm-mirror.html5beta.com' const url = 'https://github.com/electerm/electerm/releases/download/v1.0.0/test.tar.gz' // Simulate the applyProxy logic const cleanProxy = proxy.replace(/\/+$/, '') const result = `${cleanProxy}/${url}` expect(result).toBe('https://electerm-mirror.html5beta.com/https://github.com/electerm/electerm/releases/download/v1.0.0/test.tar.gz') }) test('applyProxy: handles proxy URL with trailing slash', () => { const proxy = 'https://electerm-mirror.html5beta.com/' const url = 'https://github.com/test/file.tar.gz' const cleanProxy = proxy.replace(/\/+$/, '') const result = `${cleanProxy}/${url}` expect(result).toBe('https://electerm-mirror.html5beta.com/https://github.com/test/file.tar.gz') }) test('formatBytes: formats bytes correctly', () => { expect(formatBytes(0)).toBe('0 B') expect(formatBytes(1024)).toBe('1 KB') expect(formatBytes(1048576)).toBe('1 MB') expect(formatBytes(1073741824)).toBe('1 GB') }) test('formatBytes: handles partial units', () => { expect(formatBytes(1536)).toBe('1.5 KB') expect(formatBytes(1572864)).toBe('1.5 MB') }) // ============================================================================= // Tests: Utils - Tar Extract (sync-friendly) // ============================================================================= console.log('\n=== Utils Tar Extract Tests ===\n') test('extractTarGz: extracts a tar.gz file', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-tar-')) const extDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-extract-')) try { const testFile = path.join(tmpDir, 'test.txt') fs.writeFileSync(testFile, 'Hello World') const tarFile = path.join(tmpDir, 'test.tar.gz') await tar.create({ gzip: true, file: tarFile, cwd: tmpDir }, ['test.txt']) await extractTarGz(tarFile, extDir) expect(fs.existsSync(path.join(extDir, 'test.txt'))).toBe(true) const content = fs.readFileSync(path.join(extDir, 'test.txt'), 'utf8') expect(content).toBe('Hello World') } finally { fs.rmSync(tmpDir, { recursive: true }) fs.rmSync(extDir, { recursive: true }) } }) test('extractTarGz: strips top-level directory with strip:1', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-strip-')) const extDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-strip-extract-')) try { const appDir = path.join(tmpDir, 'myapp') fs.mkdirSync(appDir) fs.writeFileSync(path.join(appDir, 'test.txt'), 'Stripped!') const tarFile = path.join(tmpDir, 'test.tar.gz') await tar.create({ gzip: true, file: tarFile, cwd: tmpDir }, ['myapp']) await extractTarGz(tarFile, extDir, 1) expect(fs.existsSync(path.join(extDir, 'test.txt'))).toBe(true) expect(fs.existsSync(path.join(extDir, 'myapp'))).toBe(false) } finally { fs.rmSync(tmpDir, { recursive: true }) fs.rmSync(extDir, { recursive: true }) } }) // ============================================================================= // Tests: CLI Launcher Flow // ============================================================================= console.log('\n=== CLI Launcher Flow Tests ===\n') test('node launcher: correct path navigation', () => { const npmDir = path.join(_packageRoot, 'npm') const expectedPackageRoot = path.resolve(npmDir, '..') expect(expectedPackageRoot).toBe(_packageRoot) }) test('node launcher: checks for electerm binary existence', () => { const expectedBinaryPath = path.join(_packageRoot, 'electerm', 'electerm') expect(expectedBinaryPath).toBe(getElectermExePath()) }) test('install flow: extractDir is correct', () => { expect(_extractDir).toBe(path.join(_packageRoot, 'electerm')) }) test('install flow: no infinite recursion', () => { // install.js does NOT call exec('electerm') // It only downloads and extracts // The node launcher then spawns the extracted binary directly const installExports = require('../../npm/install') expect(typeof installExports.isElectermExtracted).toBe('function') expect(typeof installExports.getElectermExePath).toBe('function') }) test('node launcher: launches binary after install', () => { // The Node.js launcher flow: // 1. Check if ./electerm/electerm exists // 2. If not: spawn node ./npm/install.js (downloads & extracts) // 3. Spawn ./electerm/electerm (launches binary) // // This prevents infinite recursion because: // - install.js never calls 'electerm' command // - Node.js launcher uses spawn/execFile to run the binary directly // - npm creates a proper .cmd wrapper on Windows via #!/usr/bin/env node const bashScriptPath = path.join(_packageRoot, 'npm', 'electerm') expect(fs.existsSync(bashScriptPath)).toBe(true) }) // ============================================================================= // Tests: Cross-Platform Paths // ============================================================================= console.log('\n=== Cross-Platform Path Tests ===\n') test('paths: packageRoot is absolute', () => { expect(path.isAbsolute(_packageRoot)).toBe(true) }) test('paths: extractDir is absolute', () => { expect(path.isAbsolute(_extractDir)).toBe(true) }) test('paths: extractDir is child of packageRoot', () => { expect(_extractDir.startsWith(_packageRoot)).toBe(true) }) // ============================================================================= // Async HTTP Tests (run last to avoid output interleaving) // ============================================================================= console.log('\n=== Utils HTTP Tests (async) ===\n') testAsync('httpGet: fetches a URL', async () => { const result = await httpGet('https://httpbin.org/get', 10000) expect(typeof result).toBe('string') expect(result).toContain('httpbin.org') }) testAsync('phin: makes HTTP request', async () => { const result = await phin({ url: 'https://httpbin.org/get', timeout: 10000 }) expect(result.statusCode).toBe(200) expect(Buffer.isBuffer(result.body)).toBe(true) expect(result.body.toString()).toContain('httpbin.org') }) testAsync('httpGet: handles redirects', async () => { const result = await httpGet('https://httpbin.org/redirect/1', 10000) expect(typeof result).toBe('string') }) testAsync('httpGet: throws on 404', async () => { try { await httpGet('https://httpbin.org/status/404', 10000) throw new Error('Expected to throw') } catch (e) { expect(e.message).toContain('404') } }) testAsync('download: downloads a file', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-dl-')) try { const result = await download( 'https://httpbin.org/robots.txt', tmpDir, { extract: false } ) expect(result.filepath).toContain('robots.txt') expect(result.extracted).toBe(false) expect(fs.existsSync(result.filepath)).toBe(true) } finally { fs.rmSync(tmpDir, { recursive: true }) } }) // ============================================================================= // Run all async tests and print results // ============================================================================= async function runAsyncTests () { for (const fn of asyncQueue) { await fn() } } async function printResults () { await runAsyncTests() console.log('\n========================================') console.log(`Results: ${passed} passed, ${failed} failed`) console.log('========================================\n') process.exit(failed > 0 ? 1 : 0) } printResults().catch(err => { console.error('Test runner error:', err) process.exit(1) })