Files
2026-07-13 12:46:20 +08:00

631 lines
22 KiB
JavaScript

/**
* 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)
})