Files
dify/packages/dev-proxy/src/cli.spec.ts

260 lines
7.4 KiB
TypeScript

/**
* @vitest-environment node
*/
import type { ChildProcessByStdio } from 'node:child_process'
import type { Server } from 'node:http'
import type { Readable } from 'node:stream'
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import fs from 'node:fs/promises'
import http from 'node:http'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const tempDirs: string[] = []
type DevProxyCliProcess = ChildProcessByStdio<null, Readable, Readable>
const childProcesses: DevProxyCliProcess[] = []
const httpServers: Server[] = []
const binPath = fileURLToPath(new URL('../bin/dev-proxy.js', import.meta.url))
const createTempDir = async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-cli-test-'))
tempDirs.push(tempDir)
return tempDir
}
const getFreePort = async () => {
const server = net.createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('Failed to allocate a test port.')
const { port } = address
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error)
reject(error)
else
resolve()
})
})
return port
}
const waitForOutput = (
child: DevProxyCliProcess,
output: () => string,
expectedOutput: string,
) => new Promise<void>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout>
function cleanup() {
clearTimeout(timeout)
child.stdout.off('data', onData)
child.stderr.off('data', onData)
child.off('exit', onExit)
}
function onData() {
if (!output().includes(expectedOutput))
return
cleanup()
resolve()
}
function onExit(code: number | null, signal: NodeJS.Signals | null) {
cleanup()
reject(new Error(`dev-proxy exited before writing "${expectedOutput}" with code ${code} and signal ${signal}. Output:\n${output()}`))
}
timeout = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for "${expectedOutput}". Output:\n${output()}`))
}, 3000)
child.stdout.on('data', onData)
child.stderr.on('data', onData)
child.once('exit', onExit)
onData()
})
const fetchTextWithRetry = async (url: string) => {
let lastError: unknown
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
const response = await fetch(url)
return response.text()
}
catch (error) {
lastError = error
await new Promise(resolve => setTimeout(resolve, 50))
}
}
throw lastError
}
const spawnCli = (args: readonly string[], cwd: string) => {
const child = spawn(process.execPath, [binPath, ...args], {
cwd,
env: {
...process.env,
FORCE_COLOR: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
childProcesses.push(child)
return child
}
const stopChildProcess = async (child: DevProxyCliProcess) => {
if (child.exitCode !== null || child.signalCode !== null)
return
child.kill('SIGTERM')
await once(child, 'exit')
}
const closeHttpServer = async (server: Server) => {
if (!server.listening)
return
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error)
reject(error)
else
resolve()
})
})
}
const startTextServer = async (body: string) => {
const server = http.createServer((_, response) => {
response.writeHead(200, { 'content-type': 'text/plain' })
response.end(body)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('Failed to start test server.')
httpServers.push(server)
return {
port: address.port,
}
}
describe('dev proxy CLI', () => {
afterEach(async () => {
await Promise.all(childProcesses.splice(0).map(stopChildProcess))
await Promise.all(httpServers.splice(0).map(closeHttpServer))
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
force: true,
recursive: true,
})))
})
// Scenario: help output should still be a normal short-lived command.
it('should print help and exit', async () => {
// Arrange
const tempDir = await createTempDir()
const child = spawnCli(['--help'], tempDir)
// Act
const [code] = await once(child, 'exit')
// Assert
expect(code).toBe(0)
})
// Scenario: successful server startup should keep the CLI process alive.
it('should keep running after starting the proxy server', async () => {
// Arrange
const tempDir = await createTempDir()
const port = await getFreePort()
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
export default {
routes: [{ paths: '/api', target: 'https://api.example.com' }],
}
`)
let output = ''
const child = spawnCli(['--config', './dev-proxy.config.ts', '--host', '127.0.0.1', '--port', String(port)], tempDir)
child.stdout.on('data', chunk => output += chunk.toString())
child.stderr.on('data', chunk => output += chunk.toString())
// Act
await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`)
await new Promise(resolve => setTimeout(resolve, 100))
const response = await fetch(`http://127.0.0.1:${port}/not-proxied`)
// Assert
expect(child.exitCode).toBeNull()
expect(child.signalCode).toBeNull()
expect(response.status).toBe(404)
})
// Scenario: editing the configured env file should reload route targets without restarting the CLI process.
it('should reload proxy config when the env file changes', async () => {
// Arrange
const tempDir = await createTempDir()
const port = await getFreePort()
const firstTarget = await startTextServer('first target')
const secondTarget = await startTextServer('second target')
await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${firstTarget.port}\n`)
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
export default {
routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }],
}
`)
let output = ''
const child = spawnCli([
'--config',
'./dev-proxy.config.ts',
'--env-file',
'./.env.proxy',
'--host',
'127.0.0.1',
'--port',
String(port),
], tempDir)
child.stdout.on('data', chunk => output += chunk.toString())
child.stderr.on('data', chunk => output += chunk.toString())
const proxyUrl = `http://127.0.0.1:${port}/api/ping`
// Act
await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`)
const firstResponse = await fetchTextWithRetry(proxyUrl)
await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${secondTarget.port}\n`)
await waitForOutput(child, () => output, '[dev-proxy] reloaded env file changes')
const secondResponse = await fetchTextWithRetry(proxyUrl)
// Assert
expect(firstResponse).toBe('first target')
expect(secondResponse).toBe('second target')
expect(child.exitCode).toBeNull()
expect(child.signalCode).toBeNull()
})
})