import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
import { createServer } from 'node:net'
|
import { resolve } from 'node:path'
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
interface DockerCommand {
|
command: string
|
prefix: string[]
|
}
|
|
const webRepo = resolve(import.meta.dirname, '../../..')
|
const backendRepo = resolve(webRepo, '../BatteryDetection')
|
const artifactDir = resolve(webRepo, 'tests/e2e/.artifacts')
|
const backendJar = resolve(backendRepo, 'ruoyi-admin/target/ruoyi-admin.jar')
|
const apiPort = Number(process.env.TICKET04_API_PORT || 18001)
|
const mysqlPort = Number(process.env.TICKET04_MYSQL_PORT || 63306)
|
const redisPort = Number(process.env.TICKET04_REDIS_PORT || 56379)
|
const simulatorPort = Number(process.env.TICKET04_SIMULATOR_PORT || 15021)
|
const mysqlImage = process.env.TICKET04_MYSQL_IMAGE || 'public.ecr.aws/docker/library/mysql:8.0.36'
|
const redisImage = process.env.TICKET04_REDIS_IMAGE || 'public.ecr.aws/docker/library/redis:7.2-alpine'
|
const mysqlPassword = 'ticket04-mysql'
|
const redisPassword = 'ticket04-redis'
|
const suffix = `${process.pid}`
|
|
function commandAvailable(command: string, args: string[]) {
|
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true })
|
return result.status === 0
|
}
|
|
function resolveDocker(): DockerCommand {
|
const candidates: DockerCommand[] = process.platform === 'win32'
|
? [{ command: 'docker.exe', prefix: [] }, { command: 'wsl.exe', prefix: ['-e', 'docker'] }]
|
: [{ command: 'docker', prefix: [] }]
|
const docker = candidates.find(item => commandAvailable(item.command, [...item.prefix, 'version']))
|
if (!docker) throw new Error('Ticket04 浏览器验收需要可用的 Docker 或 WSL Docker')
|
return docker
|
}
|
|
async function assertPortAvailable(port: number) {
|
await new Promise<void>((resolvePromise, reject) => {
|
const server = createServer()
|
server.once('error', () => reject(new Error(`Ticket04 隔离端口 ${port} 已被占用`)))
|
server.listen(port, '127.0.0.1', () => server.close(() => resolvePromise()))
|
})
|
}
|
|
function run(command: string, args: string[], cwd: string) {
|
const executable = process.platform === 'win32' ? 'cmd.exe' : command
|
const executableArgs = process.platform === 'win32'
|
? ['/d', '/s', '/c', command, ...args]
|
: args
|
const result = spawnSync(executable, executableArgs, {
|
cwd, encoding: 'utf8', stdio: 'inherit', windowsHide: true,
|
})
|
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`)
|
}
|
|
async function waitUntil(check: () => boolean | Promise<boolean>, message: string, timeoutMs = 120_000) {
|
const deadline = Date.now() + timeoutMs
|
while (Date.now() < deadline) {
|
if (await check()) return
|
await new Promise(resolvePromise => setTimeout(resolvePromise, 1_000))
|
}
|
throw new Error(message)
|
}
|
|
function waitForProcessClose(child: ChildProcess, timeoutMs: number) {
|
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
return new Promise<boolean>((resolvePromise) => {
|
const onClose = () => {
|
clearTimeout(timer)
|
resolvePromise(true)
|
}
|
const timer = setTimeout(() => {
|
child.off('close', onClose)
|
resolvePromise(false)
|
}, timeoutMs)
|
child.once('close', onClose)
|
})
|
}
|
|
export class Ticket04Stack {
|
private docker!: DockerCommand
|
private backend: ChildProcess | null = null
|
private readonly mysqlContainer = `battery-ticket04-mysql-${suffix}`
|
private readonly redisContainer = `battery-ticket04-redis-${suffix}`
|
|
readonly backendUrl = `http://127.0.0.1:${apiPort}`
|
|
async start() {
|
await Promise.all([mysqlPort, redisPort, apiPort, simulatorPort].map(assertPortAvailable))
|
this.docker = resolveDocker()
|
mkdirSync(artifactDir, { recursive: true })
|
writeFileSync(resolve(artifactDir, 'backend.log'), '')
|
try {
|
this.dockerRun([
|
'run', '--detach', '--rm', '--name', this.mysqlContainer,
|
'-e', `MYSQL_ROOT_PASSWORD=${mysqlPassword}`,
|
'-e', 'MYSQL_DATABASE=battery_detection',
|
'-p', `127.0.0.1:${mysqlPort}:3306`,
|
mysqlImage, '--default-authentication-plugin=mysql_native_password',
|
])
|
this.dockerRun([
|
'run', '--detach', '--rm', '--name', this.redisContainer,
|
'-p', `127.0.0.1:${redisPort}:6379`,
|
redisImage, 'redis-server', '--requirepass', redisPassword, '--appendonly', 'yes',
|
])
|
await this.waitForContainers()
|
this.buildBackend()
|
await this.startBackend()
|
this.disableCaptchaInIsolatedDatabase()
|
} catch (error) {
|
await this.stop()
|
throw error
|
}
|
}
|
|
async restartBackend() {
|
await this.stopBackend()
|
await this.startBackend()
|
}
|
|
redisCommand(...args: string[]) {
|
const result = this.dockerRun([
|
'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, '--no-auth-warning', '--raw', ...args,
|
])
|
return result.stdout.trim()
|
}
|
|
simulatorState<T>() {
|
const stored = JSON.parse(this.redisCommand('GET', 'dev:bts-simulator:state:v1'))
|
return (typeof stored === 'string' ? JSON.parse(stored) : stored) as T
|
}
|
|
backendLog() {
|
return readFileSync(resolve(artifactDir, 'backend.log'), 'utf8')
|
}
|
|
async replaceSimulatorStateAndRestart(state: unknown) {
|
await this.stopBackend()
|
const registryKey = 'dev:bts-simulator:collector-history-keys:v1'
|
const registeredHistory = this.redisCommand('SMEMBERS', registryKey)
|
.split(/\r?\n/).filter(Boolean)
|
.map(value => {
|
const decoded = JSON.parse(value)
|
return typeof decoded === 'string' ? decoded : value
|
})
|
if (registeredHistory.length > 0) this.redisCommand('DEL', ...registeredHistory)
|
this.redisCommand('DEL', registryKey)
|
this.redisCommand('SET', 'dev:bts-simulator:state:v1', JSON.stringify(JSON.stringify(state)))
|
await this.startBackend()
|
}
|
|
async stop() {
|
await this.stopBackend()
|
if (!this.docker) return
|
this.dockerRun(['rm', '--force', this.mysqlContainer], true)
|
this.dockerRun(['rm', '--force', this.redisContainer], true)
|
}
|
|
private dockerRun(args: string[], allowFailure = false) {
|
const result = spawnSync(this.docker.command, [...this.docker.prefix, ...args], {
|
encoding: 'utf8', windowsHide: true,
|
})
|
if (!allowFailure && result.status !== 0) {
|
throw new Error(`Docker 命令失败: ${result.stderr || result.stdout}`)
|
}
|
return result
|
}
|
|
private async waitForContainers() {
|
await waitUntil(() => this.dockerRun([
|
'exec', this.mysqlContainer, 'mysqladmin', 'ping', '-uroot', `-p${mysqlPassword}`, '--silent',
|
], true).status === 0, '隔离 MySQL 8 未就绪')
|
await waitUntil(() => this.dockerRun([
|
'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, 'ping',
|
], true).status === 0, '隔离 Redis 未就绪')
|
}
|
|
private buildBackend() {
|
if (process.env.TICKET04_SKIP_BACKEND_BUILD === '1' && existsSync(backendJar)) return
|
run(process.platform === 'win32' ? 'mvn.cmd' : 'mvn',
|
['-pl', 'ruoyi-admin', '-am', 'package', '-DskipTests'], backendRepo)
|
if (!existsSync(backendJar)) throw new Error(`未生成后端可执行包: ${backendJar}`)
|
}
|
|
private async startBackend() {
|
const logPath = resolve(artifactDir, 'backend.log')
|
const log = createWriteStream(logPath, { flags: 'a' })
|
const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8`
|
this.backend = spawn('java', [
|
'-jar', backendJar,
|
`--server.port=${apiPort}`,
|
`--spring.datasource.druid.master.url=${jdbcUrl}`,
|
'--spring.datasource.druid.master.username=root',
|
`--spring.datasource.druid.master.password=${mysqlPassword}`,
|
'--spring.data.redis.host=127.0.0.1',
|
`--spring.data.redis.port=${redisPort}`,
|
`--spring.data.redis.password=${redisPassword}`,
|
'--spring.data.redis.database=0',
|
'--spring.profiles.active=druid,dev-simulator',
|
'--spring.devtools.restart.enabled=false',
|
`--battery.dev-simulator.port=${simulatorPort}`,
|
`--ruoyi.profile=${artifactDir}`,
|
], {
|
cwd: backendRepo,
|
env: { ...process.env, LOG_PATH: artifactDir },
|
windowsHide: true,
|
stdio: ['ignore', 'pipe', 'pipe'],
|
})
|
this.backend.stdout?.pipe(log)
|
this.backend.stderr?.pipe(log)
|
await waitUntil(async () => {
|
if (this.backend?.exitCode !== null) throw new Error(`后端提前退出,详见 ${logPath}`)
|
try {
|
return (await fetch(`${this.backendUrl}/dev/bts-simulator/state`)).ok
|
} catch {
|
return false
|
}
|
}, `后端未就绪,详见 ${logPath}`, 180_000)
|
}
|
|
private disableCaptchaInIsolatedDatabase() {
|
this.dockerRun([
|
'exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
|
'-e', "UPDATE battery_detection.sys_config SET config_value='false' WHERE config_key='sys.account.captchaEnabled'",
|
])
|
}
|
|
private async stopBackend() {
|
const backend = this.backend
|
this.backend = null
|
if (!backend || backend.exitCode !== null || backend.signalCode !== null) return
|
const gracefulClose = waitForProcessClose(backend, 15_000)
|
backend.kill('SIGTERM')
|
if (await gracefulClose) return
|
if (backend.exitCode === null && backend.signalCode === null) {
|
const forcedClose = waitForProcessClose(backend, 15_000)
|
if (process.platform === 'win32') {
|
spawnSync('taskkill.exe', ['/pid', String(backend.pid), '/t', '/f'], { windowsHide: true })
|
} else {
|
backend.kill('SIGKILL')
|
}
|
await forcedClose
|
}
|
}
|
}
|