From cc15d003d41151a459f7b0b95b74de452bce5159 Mon Sep 17 00:00:00 2001 From: sganggs Date: Fri, 9 Oct 2026 18:58:36 +0800 Subject: [PATCH] Clean up browser test pipes and scope deliberate outage errors --- docs/history/0.2.3.md | 9 +++++ test/e2e/client-close.test.js | 51 +++++++++++++++++++++++++++ test/e2e/client.mjs | 10 +++++- test/ui/build-runtime.browser.test.js | 14 +++++--- 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 test/e2e/client-close.test.js diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 6048c88d..5a7ae740 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -178,3 +178,12 @@ lines; the official-table comparison still checks every potential, skill and mod The official EN overlay has no launch-day Clementia talents yet: overall coverage remains ≥97%, with the measured talent floor explicitly updated to 94.9%; untranslated text keeps its Chinese fallback. The old Saria no-enemy assertion is corrected to require actual healing, and development builds retain the standard top-of-README notice. + + +## §28.12 浏览器验收退出时释放自有日志管道 + +完整浏览器验收中,真实对局与观战的断言已经全部通过,但 Chrome 的崩溃报告/更新辅助进程继承了日志管道,测试进程仍在等待读取结束。共用测试客户端现在在浏览器子进程明确退出后释放它的 stdio;关闭失败、子进程仍活着时不关闭管道,也不操作其他浏览器进程。游戏运行时代码与战斗规则不变。 + +新增真实子进程继承 stderr 的回归,以及关闭失败/信号退出的边界检查;验收要求原异常文件单独重跑并确认正常结束。首轮异常与复测结果分别保留在本轮验收记录中。 + +同一轮的版本重连测试在刻意停服时,Chrome 可能报告连接拒绝、连接重置或未连接的 socket。测试只在主动停服至恢复握手的窗口内排除这些已知传输日志;正常运行期间仍检查它们,脚本异常始终保留,所有版本指纹、重连及换局刷新断言不变。 diff --git a/test/e2e/client-close.test.js b/test/e2e/client-close.test.js new file mode 100644 index 00000000..c8534a09 --- /dev/null +++ b/test/e2e/client-close.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { Client } from './client.mjs'; + +test('closing an exited browser releases stderr even when a helper inherited its pipe', { timeout: 5000 }, async () => { + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + const helper = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 5000)'], + { stdio: ['ignore', 'ignore', 2], detached: true }); + helper.unref(); + process.send({ pid: helper.pid }, () => process.exit(0)); + `], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + const exited = once(child, 'exit'); + let helperPid; + try { + [{ pid: helperPid }] = await once(child, 'message'); + child.stderr.resume(); + await exited; + assert.equal(child.exitCode, 0); + assert.equal(child.stderr.destroyed, false, 'the helper still holds the other end of stderr'); + const client = new Client(null, '', 'cleanup'); + client.browser = { process: () => child, close: async () => {} }; + await client.close(); + assert.equal(child.stderr.destroyed, true, 'the exited browser no longer keeps this test alive'); + } finally { + if (helperPid) { try { process.kill(helperPid, 'SIGTERM'); } catch { /* already exited */ } } + for (const stream of child.stdio) stream?.destroy?.(); + if (child.exitCode == null && child.signalCode == null) child.kill(); + } +}); + +test('a failed shutdown leaves a live browser and its pipes alone', async () => { + let destroyed = 0; + const child = { exitCode: null, signalCode: null, stdio: [{ destroy: () => destroyed++ }] }; + const client = new Client(null, '', 'cleanup'); + client.browser = { process: () => child, close: async () => { throw new Error('not closed'); } }; + await client.close(); + assert.equal(destroyed, 0); +}); + +test('a browser terminated by a signal releases only its owned pipes; an absent browser is harmless', async () => { + let destroyed = 0; + const client = new Client(null, '', 'cleanup'); + await client.close(); + client.browser = { process: () => ({ exitCode: null, signalCode: 'SIGTERM', stdio: [null, { destroy: () => destroyed++ }] }), + close: async () => {} }; + await client.close(); + assert.equal(destroyed, 1); +}); diff --git a/test/e2e/client.mjs b/test/e2e/client.mjs index f8790577..74318fba 100644 --- a/test/e2e/client.mjs +++ b/test/e2e/client.mjs @@ -189,7 +189,15 @@ export class Client { throw new Error(`${this.label}: timed out waiting for ${what} (last: ${JSON.stringify(last)})`); } - async close() { await this.browser?.close().catch(() => {}); } + async close() { + const child = this.browser?.process(); + await this.browser?.close().catch(() => {}); + // Chrome's crash/update helpers can inherit stderr after the launched browser exits. Release only our + // exited child's pipes; otherwise readline keeps the test alive after all its assertions have finished. + if (child && (child.exitCode != null || child.signalCode != null)) { + for (const stream of child.stdio) stream?.destroy?.(); + } + } /** Real mouse click on the `nth` visible, uncovered element matching `sel` whose text includes `text`. */ async click(sel, text = null, { timeout = 15000, optional = false, nth = 0, button = 'left', any = false } = {}) { diff --git a/test/ui/build-runtime.browser.test.js b/test/ui/build-runtime.browser.test.js index ec038991..0051faf5 100644 --- a/test/ui/build-runtime.browser.test.js +++ b/test/ui/build-runtime.browser.test.js @@ -108,13 +108,13 @@ test('an old simulator page reconnects safely, then reloads before the next matc args: ['--no-first-run', '--no-sandbox'] }); const page = await browser.newPage(); const problems = []; - let navigations = 0, simRequests = 0; + let navigations = 0, simRequests = 0, plannedOutage = false; page.on('pageerror', (error) => problems.push(error.message)); page.on('console', (message) => { - // The two deliberate server outages produce browser transport errors, not application errors. - if (message.type() === 'error' && !/net::ERR_CONNECTION_REFUSED|WebSocket connection to .* failed/.test(message.text())) { - problems.push(message.text()); - } + // Only the fixture's deliberate outages may produce these transport errors. Record the same errors + // during normal operation, and always retain application errors (including pageerror above). + const outageTransport = plannedOutage && /net::ERR_(?:CONNECTION_(?:REFUSED|RESET)|SOCKET_NOT_CONNECTED)|WebSocket connection to .* failed/.test(message.text()); + if (message.type() === 'error' && !outageTransport) problems.push(message.text()); }); page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) navigations++; }); page.on('request', (request) => { if (new URL(request.url()).pathname === '/sim/rng.js') simRequests++; }); @@ -132,6 +132,7 @@ test('an old simulator page reconnects safely, then reloads before the next matc assert.equal(old.route, 'game'); assert.equal(simRequests, 1, 'the first match imported the actual simulator'); + plannedOutage = true; await server.stop(); await page.waitForFunction(() => window.fixture.net.status === 'reconnecting'); assert.equal((await check()).status, 'unknown', 'a stopped server never causes a mid-match reload'); @@ -153,6 +154,7 @@ test('an old simulator page reconnects safely, then reloads before the next matc server = await start(root, port); await ready(); + plannedOutage = false; assert.equal((await state()).boots, 1, 'the original page actually reconnected'); assert.equal((await state()).fingerprint, old.fingerprint, 'the already loaded simulator still has old code'); assert.equal(simRequests, 1, 'reconnect does not refetch an imported ES module'); @@ -166,12 +168,14 @@ test('an old simulator page reconnects safely, then reloads before the next matc assert.equal(navigations, 1, 'settlement also remains on screen'); // Even an already-confirmed update must wait while the server is unavailable. + plannedOutage = true; await server.stop(); await page.click('#lobby'); assert.equal((await check()).status, 'unknown'); assert.equal(navigations, 1); server = await start(root, port); await ready(); + plannedOutage = false; assert.equal((await check()).status, 'new', 'the failed check resets consecutive confirmations'); assert.equal(navigations, 1, 'one successful check after downtime is insufficient'); await Promise.all([