import fs from 'fs'
import path from 'path'
import { spawnSync } from 'child_process'
/**
* 逐檔隔離執行e2e:每個e2e檔以獨立之mocha行程執行,檔與檔之間由beforeEachFile換新後端等狀態
*
* why:多個e2e檔塞在單一mocha行程時,會共用被前面測試改過狀態之後端(記憶體內計數、快取、換過設定之實例、RPC正規化過之欄位順序),
* 與各檔單獨產製標準圖之環境不同;逐檔各給全新後端即回到單獨執行之狀態。前端通常無狀態且啟動慢,由各檔之startServersOnce沿用。
* 測試檔以pattern於testDir動態列舉(新增之檔自動納入,不因寫死清單而靜默漏跑),依檔名排序執行。
* 只重跑受改動影響之案例時(依差異範圍驗證),以targets逐檔指定檔案與--grep:每檔仍換新後端,並自動加--fail-zero(grep對不到任何案例時失敗,不假綠)。
* 殺port等屬專案政策,一律以beforeEachFile/afterAll回呼注入,本函數不殺任何行程
*
* 啟動方式:有mocha執行檔(mochaBin,預設projRoot下之node_modules/mocha/bin/mocha.js)時以目前之node直接執行之(不經shell),否則沿用npx mocha(Windows下經shell)。
* why:Windows下npx須經cmd.exe,參數被拼接成一行後由cmd.exe重新解析(2026-09-30實測):| & 切出另一指令(如'E2E-0(0[2-9]|1[0-5])-'被切成管線而exit 255),
* < > 為轉向(> 把輸出寫成cwd下之檔案且exit 0),^ 與 " 被吃掉、空白拆成多個參數、%名稱% 展開為環境變數(皆exit 0,靜默改變篩選);
* 括號、[ ]、! , ; = * ? \ 與中文原樣傳遞。Node 24另警告DEP0190。故經npx且於Windows時,參數含空白或 " & | < > ^ % 即於執行前拋錯,不跑被改寫之指令。
* mocha執行檔之路徑一律以projRoot解析為絕對路徑(spawn之cwd為projRoot,相對路徑會被重複疊加);明確給定之mochaBin不存在即拋錯,不默默改用npx。
*
* @param {Object} opt 輸入設定物件
* @param {String} opt.projRoot 輸入專案根目錄字串(mocha之工作目錄)
* @param {String} opt.testDir 輸入測試檔目錄字串
* @param {RegExp} [opt.pattern=/^e2e-.*\.test\.mjs$/] 輸入測試檔名樣式,預設/^e2e-.*\.test\.mjs$/,有給targets時不使用
* @param {Array} [opt.targets] 輸入指定執行之目標陣列,元素為檔名字串或{file,grep}物件,依陣列順序執行;有grep者附加['--grep',grep,'--fail-zero'];檔案不存在即拋錯。未給時依pattern列舉
* @param {Function} [opt.beforeEachFile] 輸入每檔執行前之回呼(可回傳Promise),傳入檔名,預設無動作
* @param {Function} [opt.afterAll] 輸入全部執行後之回呼(可回傳Promise),預設無動作
* @param {Array} [opt.mochaArgs=['--reporter','list','--timeout','300000']] 輸入附加於檔案路徑後之mocha參數陣列
* @param {String} [opt.mochaBin] 輸入mocha執行檔路徑字串(相對路徑以projRoot解析),預設為projRoot下之node_modules/mocha/bin/mocha.js,預設者不存在時改用npx;明確給定而不存在即拋錯
* @param {Function} [opt.spawnSyncFn=spawnSync] 輸入同步spawn函數(供測試注入),預設spawnSync
* @param {String} [opt.platform=process.platform] 輸入作業系統字串(供測試注入),預設process.platform
* @param {Function} [opt.log=console.log] 輸入輸出函數,預設console.log
* @returns {Promise} 回傳Promise,resolve回傳物件{results,failed},results為[{file,code}](有grep者另含grep),failed為失敗檔數
* @example
*
* import runIsolatedE2e from 'w-package-tools-e2e/src/runIsolatedE2e.mjs'
* import killPortListeners from 'w-package-tools-e2e/src/killPortListeners.mjs'
*
* let { failed } = await runIsolatedE2e({
* projRoot, testDir,
* beforeEachFile: async () => { killPortListeners(11007); await new Promise((r) => setTimeout(r, 2000)) },
* afterAll: () => { killPortListeners(11007) },
* })
* process.exit(failed === 0 ? 0 : 1)
*
* //只重跑受影響之案例(每檔仍換新後端)
* await runIsolatedE2e({
* projRoot, testDir,
* targets: [{ file: 'e2e-adduser.test.mjs', grep: 'E2E-0(0[2-9]|1[0-5])-' }, { file: 'e2e-ips.test.mjs', grep: 'E2E-004-' }],
* beforeEachFile, afterAll,
* })
*
*/
async function runIsolatedE2e(opt = {}) {
let {
projRoot,
testDir,
pattern = /^e2e-.*\.test\.mjs$/,
targets = null,
beforeEachFile = async () => {},
afterAll = async () => {},
mochaArgs = ['--reporter', 'list', '--timeout', '300000'],
mochaBin = null,
spawnSyncFn = spawnSync,
platform = process.platform,
log = console.log,
} = opt
if (!projRoot || !testDir) {
throw new Error('runIsolatedE2e: projRoot 與 testDir 為必填')
}
let isWin = platform === 'win32'
let rel = path.relative(projRoot, testDir)
//執行清單: targets 依給定順序; 否則依 pattern 列舉並排序
let items
if (Array.isArray(targets)) {
items = targets.map((t) => (typeof t === 'string' ? { file: t, grep: '' } : { file: t.file, grep: t.grep || '' }))
let lack = items.filter((t) => !t.file || !fs.existsSync(path.join(testDir, t.file))).map((t) => t.file)
if (lack.length > 0) {
throw new Error(`runIsolatedE2e: targets 之檔案不存在: ${lack.join(', ')}`)
}
}
else {
items = fs.readdirSync(testDir).filter((f) => pattern.test(f)).sort().map((f) => ({ file: f, grep: '' }))
}
//mocha 啟動方式: 有 mocha 執行檔即以 node 直接執行(不經 shell), 否則 npx; 路徑以 projRoot 解析為絕對路徑(spawn 之 cwd 為 projRoot)
let bin = path.resolve(projRoot, mochaBin || path.join('node_modules', 'mocha', 'bin', 'mocha.js'))
let useNode = fs.existsSync(bin)
if (mochaBin && !useNode) {
throw new Error(`runIsolatedE2e: mochaBin 不存在: ${bin}`)
}
//Windows 下之 npx 退路經 cmd.exe 重新解析參數: 含空白或 " & | < > ^ % 者會被拆開、吃掉、展開或轉向, 執行前即拋錯(一個都不跑)
if (!useNode && isWin) {
let bad = items
.flatMap(({ file, grep }) => [path.join(rel, file), ...mochaArgs, ...(grep ? [grep] : [])])
.filter((a, i, arr) => /[\s"&|<>^%]/.test(a) && arr.indexOf(a) === i)
if (bad.length > 0) {
throw new Error(`runIsolatedE2e: 無本地 mocha, 經 npx(cmd.exe)執行時下列參數含空白或 " & | < > ^ % 會被改寫: ${bad.join(', ')}; 請將 mocha 裝為 devDependency 或以 mochaBin 指定`)
}
}
let results = []
for (let { file, grep } of items) {
await beforeEachFile(file)
log(`\n=== [run-e2e-isolated] 執行 ${file}${grep ? ` --grep ${grep}` : ''}(全新後端)===`)
let args = [path.join(rel, file), ...mochaArgs, ...(grep ? ['--grep', grep, '--fail-zero'] : [])]
let r = useNode
? spawnSyncFn(process.execPath, [bin, ...args], { cwd: projRoot, stdio: 'inherit', shell: false })
: spawnSyncFn('npx', ['mocha', ...args], { cwd: projRoot, stdio: 'inherit', shell: isWin })
results.push(grep ? { file, grep, code: r.status } : { file, code: r.status })
}
await afterAll()
log('\n=== [run-e2e-isolated] 逐檔結果 ===')
let failed = 0
for (let { file, grep, code } of results) {
log(` ${code === 0 ? '✔' : '✘'} ${file}${grep ? ` --grep ${grep}` : ''} (exit ${code})`)
if (code !== 0) {
failed++
}
}
log(`\n${failed === 0 ? '✔ e2e 全部通過' : `✘ ${failed} 個 e2e 檔失敗`}`)
return { results, failed }
}
export default runIsolatedE2e