Skip to content

Commit 3b19510

Browse files
committed
test: migrate test suite to TypeScript
- Convert 12 test files from JavaScript to TypeScript - Add shared test types and utilities (tests/types/index.ts) - Update test runner to support mixed JS/TS environments - Properly separate Node test runner from Vitest tests - Convert helper files and Playwright constants to TypeScript - Maintain full test coverage with 82 tests passing - Ensure TypeScript compilation and linting pass with zero errors The test suite now benefits from full type safety while preserving complete functionality and test coverage.
1 parent 10eb3cc commit 3b19510

20 files changed

+825
-458
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@
5353
"dev": "NODE_ENV=development DEBUG=webssh2:* tsx watch index.ts",
5454
"dev:ts": "NODE_ENV=development DEBUG=webssh2:* tsx watch index.ts",
5555
"watch": "npm run dev",
56-
"test": "npm run build && WEBSSH2_SKIP_NETWORK=1 node scripts/run-node-tests.mjs",
56+
"test": "npm run build && WEBSSH2_SKIP_NETWORK=1 npx tsx scripts/run-node-tests.mts",
57+
"test:js": "npm run build && WEBSSH2_SKIP_NETWORK=1 node scripts/run-node-tests.mjs",
58+
"test:ts": "npm run build && WEBSSH2_SKIP_NETWORK=1 npx tsx scripts/run-node-tests.mts",
5759
"verify": "npm run lint && npm run test",
5860
"progress": "echo 'Migration progress script removed; see DOCS/CONSTANTS.md and ts-migration-progress.md if present.'",
59-
"test:all": "npm run build && WEBSSH2_SKIP_NETWORK=0 node scripts/run-node-tests.mjs",
61+
"test:all": "npm run build && WEBSSH2_SKIP_NETWORK=0 npx tsx scripts/run-node-tests.mts",
6062
"test:e2e": "npm run build && ENABLE_E2E_SSH=1 playwright test -c playwright.config.ts tests/playwright",
6163
"test:e2e:debug": "npm run build && ENABLE_E2E_SSH=1 E2E_DEBUG=webssh2:* playwright test -c playwright.config.ts tests/playwright",
6264
"test:performance": "echo 'Performance tests archived - see tests/playwright/performance/PERFORMANCE_TESTS.md'",

scripts/run-node-tests.mts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env node
2+
import { readdirSync, statSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { spawn } from 'node:child_process'
5+
import { fileURLToPath } from 'node:url'
6+
7+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
8+
const testsDir = join(process.cwd(), 'tests')
9+
10+
interface TestFile {
11+
path: string
12+
isTypeScript: boolean
13+
}
14+
15+
function walk(dir: string): TestFile[] {
16+
const entries = readdirSync(dir)
17+
const files: TestFile[] = []
18+
19+
for (const entry of entries) {
20+
const full = join(dir, entry)
21+
const st = statSync(full)
22+
23+
if (st.isDirectory()) {
24+
// Skip Playwright tests from node test runner
25+
if (full.includes(join('tests', 'playwright'))) continue
26+
files.push(...walk(full))
27+
} else if (entry.endsWith('.test.js')) {
28+
files.push({ path: full, isTypeScript: false })
29+
} else if (entry.endsWith('.test.ts')) {
30+
// Skip Vitest files (they have their own runner)
31+
// Also skip files in unit/ and integration/ dirs that use Vitest
32+
if (entry.includes('.vitest.')) continue
33+
if (full.includes(join('tests', 'unit')) || full.includes(join('tests', 'integration'))) {
34+
// These directories contain Vitest tests
35+
continue
36+
}
37+
files.push({ path: full, isTypeScript: true })
38+
}
39+
}
40+
41+
return files
42+
}
43+
44+
let testFiles = walk(testsDir)
45+
46+
// Optionally skip network-dependent tests in restricted environments
47+
const skipNetwork = ['1', 'true', 'yes'].includes(
48+
String(process.env.WEBSSH2_SKIP_NETWORK || '').toLowerCase()
49+
)
50+
51+
if (skipNetwork) {
52+
testFiles = testFiles.filter((f) => !/\/ssh\.test\.(js|ts)$/.test(f.path))
53+
// Also skip HTTP route tests that bind/listen via supertest in restricted envs
54+
testFiles = testFiles.filter((f) => !/\/post-auth\.test\.(js|ts)$/.test(f.path))
55+
}
56+
57+
if (testFiles.length === 0) {
58+
console.error('No test files found to run.')
59+
process.exit(1)
60+
}
61+
62+
// Separate TypeScript and JavaScript files
63+
const tsFiles = testFiles.filter(f => f.isTypeScript)
64+
const jsFiles = testFiles.filter(f => !f.isTypeScript)
65+
66+
console.log(`Found ${testFiles.length} test files (${tsFiles.length} TypeScript, ${jsFiles.length} JavaScript)`)
67+
68+
// Function to run tests
69+
async function runTests(): Promise<void> {
70+
let exitCode = 0
71+
72+
// Run TypeScript tests with tsx if any exist
73+
if (tsFiles.length > 0) {
74+
console.log('\n📝 Running TypeScript tests...')
75+
const tsxArgs = [
76+
'--test',
77+
'--test-concurrency=1',
78+
...tsFiles.map(f => f.path)
79+
]
80+
81+
const tsxChild = spawn(
82+
'npx',
83+
['tsx', ...tsxArgs],
84+
{ stdio: 'inherit' }
85+
)
86+
87+
const tsExitCode = await new Promise<number>((resolve) => {
88+
tsxChild.on('exit', (code, signal) => {
89+
if (signal) {
90+
console.error(`TypeScript tests exited with signal ${signal}`)
91+
resolve(1)
92+
} else {
93+
resolve(code ?? 1)
94+
}
95+
})
96+
})
97+
98+
if (tsExitCode !== 0) {
99+
exitCode = tsExitCode
100+
}
101+
}
102+
103+
// Run JavaScript tests with Node.js test runner if any exist
104+
if (jsFiles.length > 0) {
105+
console.log('\n📄 Running JavaScript tests...')
106+
const nodeChild = spawn(
107+
process.execPath,
108+
['--test', '--test-concurrency=1', ...jsFiles.map(f => f.path)],
109+
{ stdio: 'inherit' }
110+
)
111+
112+
const jsExitCode = await new Promise<number>((resolve) => {
113+
nodeChild.on('exit', (code, signal) => {
114+
if (signal) {
115+
console.error(`JavaScript tests exited with signal ${signal}`)
116+
resolve(1)
117+
} else {
118+
resolve(code ?? 1)
119+
}
120+
})
121+
})
122+
123+
if (jsExitCode !== 0) {
124+
exitCode = jsExitCode
125+
}
126+
}
127+
128+
process.exit(exitCode)
129+
}
130+
131+
// Run the tests
132+
runTests().catch((error) => {
133+
console.error('Error running tests:', error)
134+
process.exit(1)
135+
})
Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
// server
2-
// tests/config-async.test.js
1+
// tests/config-async.test.ts
32

43
import { test, describe, beforeEach, afterEach } from 'node:test'
54
import assert from 'node:assert/strict'
6-
import { fileURLToPath } from 'url'
7-
import { dirname, join } from 'path'
8-
import fs from 'fs'
5+
import { fileURLToPath } from 'node:url'
6+
import { dirname, join } from 'node:path'
7+
import fs from 'node:fs'
98
import { getConfig, loadConfigAsync, resetConfigForTesting } from '../dist/app/config.js'
109
import { cleanupEnvironmentVariables, storeEnvironmentVariables, restoreEnvironmentVariables } from './test-helpers.js'
10+
import type { TestEnvironment } from './types/index.js'
1111

1212
// Ensure clean state at module load
1313
resetConfigForTesting()
@@ -18,7 +18,7 @@ const __dirname = dirname(__filename)
1818
describe('Config Module - Async Tests', () => {
1919
const configPath = join(__dirname, '..', 'config.json')
2020
const backupPath = join(__dirname, '..', 'config.json.backup')
21-
let originalEnv
21+
let originalEnv: TestEnvironment
2222

2323
beforeEach(() => {
2424
// Store original environment variables
@@ -50,7 +50,7 @@ describe('Config Module - Async Tests', () => {
5050
}
5151
} catch (error) {
5252
// Ignore cleanup errors in tests
53-
console.warn('Test cleanup warning:', error.message)
53+
console.warn('Test cleanup warning:', (error as Error).message)
5454
}
5555
})
5656

@@ -149,7 +149,7 @@ describe('Config Module - Async Tests', () => {
149149
const config = await getConfig()
150150

151151
assert.equal(config.listen.port, 5555)
152-
assert.ok(config.ssh.algorithms.cipher.includes('aes256-gcm@openssh.com'))
152+
assert.ok(config.ssh.algorithms?.cipher?.includes('aes256-gcm@openssh.com'))
153153
assert.ok(typeof config.getCorsConfig === 'function')
154154
})
155155

@@ -213,14 +213,14 @@ describe('Config Module - Async Tests', () => {
213213

214214
const config = await loadConfigAsync()
215215

216-
assert.ok(config.ssh.algorithms.cipher.includes('aes256-gcm@openssh.com'))
217-
assert.ok(config.ssh.algorithms.cipher.includes('aes128-ctr'))
218-
assert.ok(config.ssh.algorithms.kex.includes('ecdh-sha2-nistp256'))
219-
assert.ok(config.ssh.algorithms.hmac.includes('hmac-sha2-512'))
216+
assert.ok(config.ssh.algorithms?.cipher?.includes('aes256-gcm@openssh.com'))
217+
assert.ok(config.ssh.algorithms?.cipher?.includes('aes128-ctr'))
218+
assert.ok(config.ssh.algorithms?.kex?.includes('ecdh-sha2-nistp256'))
219+
assert.ok(config.ssh.algorithms?.hmac?.includes('hmac-sha2-512'))
220220

221221
// Should still have other default algorithms
222-
assert.ok(config.ssh.algorithms.serverHostKey.length > 0)
223-
assert.ok(config.ssh.algorithms.compress.length > 0)
222+
assert.ok(config.ssh.algorithms?.serverHostKey && config.ssh.algorithms.serverHostKey.length > 0)
223+
assert.ok(config.ssh.algorithms?.compress && config.ssh.algorithms.compress.length > 0)
224224
})
225225

226226
test('concurrent calls to getConfig return the same instance', async () => {
@@ -241,4 +241,4 @@ describe('Config Module - Async Tests', () => {
241241
assert.strictEqual(config2, config3)
242242
assert.equal(config1.listen.port, 6666)
243243
})
244-
})
244+
})
Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
// server
2-
// tests/config.test.js
1+
// tests/config.test.ts
32

43
import { test, describe, beforeEach, afterEach } from 'node:test'
54
import assert from 'node:assert/strict'
6-
import { fileURLToPath } from 'url'
7-
import { dirname, join } from 'path'
8-
import fs from 'fs'
5+
import { fileURLToPath } from 'node:url'
6+
import { dirname, join } from 'node:path'
7+
import fs from 'node:fs'
98
import { resetConfigForTesting, getConfig } from '../dist/app/config.js'
109
import { cleanupEnvironmentVariables, storeEnvironmentVariables, restoreEnvironmentVariables } from './test-helpers.js'
10+
import type { TestEnvironment } from './types/index.js'
11+
import type { Config } from '../app/config.js'
1112

1213
// Ensure clean state at module load
1314
resetConfigForTesting()
@@ -18,7 +19,7 @@ const __dirname = dirname(__filename)
1819
describe('Config Module - Baseline Sync Tests', () => {
1920
const configPath = join(__dirname, '..', 'config.json')
2021
const backupPath = join(__dirname, '..', 'config.json.backup')
21-
let originalEnv = {}
22+
let originalEnv: TestEnvironment = {}
2223

2324
beforeEach(() => {
2425
// Store original environment variables
@@ -139,13 +140,13 @@ describe('Config Module - Baseline Sync Tests', () => {
139140
const config = await getConfig()
140141

141142
// Custom algorithms should be present
142-
assert.ok(config.ssh.algorithms.cipher.includes('aes256-gcm@openssh.com'))
143-
assert.ok(config.ssh.algorithms.cipher.includes('aes256-cbc'))
144-
assert.ok(config.ssh.algorithms.kex.includes('ecdh-sha2-nistp256'))
143+
assert.ok(config.ssh.algorithms?.cipher?.includes('aes256-gcm@openssh.com'))
144+
assert.ok(config.ssh.algorithms?.cipher?.includes('aes256-cbc'))
145+
assert.ok(config.ssh.algorithms?.kex?.includes('ecdh-sha2-nistp256'))
145146

146147
// Other algorithm categories should have defaults
147-
assert.ok(config.ssh.algorithms.hmac.length > 0)
148-
assert.ok(config.ssh.algorithms.serverHostKey.length > 0)
148+
assert.ok(config.ssh.algorithms?.hmac && config.ssh.algorithms.hmac.length > 0)
149+
assert.ok(config.ssh.algorithms?.serverHostKey && config.ssh.algorithms.serverHostKey.length > 0)
149150
})
150151

151152
test('exports getCorsConfig function', async () => {
@@ -214,4 +215,4 @@ describe('Config Module - Baseline Sync Tests', () => {
214215
assert.equal(config.options.allowReconnect, true)
215216
assert.equal(config.options.allowReplay, true)
216217
})
217-
})
218+
})
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ const minimalConfig = {
1212
options: { challengeButton: true, autoLog: false, allowReplay: true, allowReconnect: true, allowReauth: true },
1313
}
1414

15-
function getRouteMap(router) {
15+
function getRouteMap(router: any) {
1616
const layers = router.stack || []
17-
/** @type {Record<string, Set<string>>} */
18-
const byPath = {}
17+
const byPath: Record<string, Set<string>> = {}
1918
for (const l of layers) {
2019
if (!l.route) continue
2120
const p = l.route.path
@@ -49,4 +48,4 @@ test('router registers expected paths and methods', () => {
4948

5049
assert.ok(byPath['/force-reconnect'], 'GET /force-reconnect present')
5150
assert.ok(byPath['/force-reconnect'].includes('get'))
52-
})
51+
})
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events'
44
import socketHandler from '../../dist/app/socket.js'
55

66
describe('Socket.IO Contracts', () => {
7-
let io, mockSocket, mockConfig, MockSSHConnection
7+
let io: any, mockSocket: any, mockConfig: any, MockSSHConnection: any
88

99
beforeEach(() => {
1010
io = new EventEmitter()
@@ -13,7 +13,7 @@ describe('Socket.IO Contracts', () => {
1313
mockSocket = new EventEmitter()
1414
mockSocket.id = 'test-socket-id'
1515
mockSocket.request = {
16-
session: { save: mock.fn((cb) => cb()), sshCredentials: null, usedBasicAuth: false },
16+
session: { save: mock.fn((cb: () => void) => cb()), sshCredentials: null, usedBasicAuth: false },
1717
}
1818
mockSocket.emit = mock.fn()
1919
mockSocket.disconnect = mock.fn()
@@ -36,35 +36,35 @@ describe('Socket.IO Contracts', () => {
3636
})
3737

3838
it('emits authentication(request_auth) on new connection without basic auth', () => {
39-
const connectionHandler = io.on.mock.calls[0].arguments[1]
39+
const connectionHandler = (io.on as any).mock.calls[0].arguments[1]
4040
connectionHandler(mockSocket)
41-
const [event, payload] = mockSocket.emit.mock.calls[0].arguments
41+
const [event, payload] = (mockSocket.emit as any).mock.calls[0].arguments
4242
assert.equal(event, 'authentication')
4343
assert.deepEqual(payload, { action: 'request_auth' })
4444
})
4545

4646
it('emits explicit failure when authenticate payload invalid', () => {
47-
const connectionHandler = io.on.mock.calls[0].arguments[1]
47+
const connectionHandler = (io.on as any).mock.calls[0].arguments[1]
4848
connectionHandler(mockSocket)
4949
// reset emitted calls by reassigning a fresh spy
5050
mockSocket.emit = mock.fn()
5151
EventEmitter.prototype.emit.call(mockSocket, 'authenticate', { host: 'h' })
52-
const authEvents = mockSocket.emit.mock.calls.filter((c) => c.arguments[0] === 'authentication')
52+
const authEvents = (mockSocket.emit as any).mock.calls.filter((c: any) => c.arguments[0] === 'authentication')
5353
assert.ok(authEvents.length > 0)
5454
const last = authEvents[authEvents.length - 1].arguments[1]
5555
assert.equal(last.success, false)
5656
assert.match(String(last.message || ''), /Invalid credentials/i)
5757
})
5858

5959
it('emits permissions after successful connection with expected flags', async () => {
60-
const connectionHandler = io.on.mock.calls[0].arguments[1]
60+
const connectionHandler = (io.on as any).mock.calls[0].arguments[1]
6161
mockSocket.request.session.usedBasicAuth = true
6262
mockSocket.request.session.sshCredentials = { host: 'h', port: 22, username: 'u', password: 'p' }
6363
connectionHandler(mockSocket)
6464
await new Promise((r) => setImmediate(r))
65-
const permEvent = mockSocket.emit.mock.calls.find((c) => c.arguments[0] === 'permissions')
65+
const permEvent = (mockSocket.emit as any).mock.calls.find((c: any) => c.arguments[0] === 'permissions')
6666
assert.ok(permEvent, 'permissions event emitted')
6767
const perms = permEvent.arguments[1]
6868
assert.deepEqual(Object.keys(perms).sort(), ['allowReauth', 'allowReconnect', 'allowReplay', 'autoLog'].sort())
6969
})
70-
})
70+
})

0 commit comments

Comments
 (0)