Clean up browser test pipes and scope deliberate outage errors

This commit is contained in:
sganggs committed 2026-10-09 18:58:36 +08:00
1 parent 0b0ce4fbd7
commit cc15d003d4
4 files changed
+78 -6

No files matched your search

+9
View File
@@ -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。测试只在主动停服至恢复握手的窗口内排除这些已知传输日志;正常运行期间仍检查它们,脚本异常始终保留,所有版本指纹、重连及换局刷新断言不变。
+51
View File
@@ -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);
});
+9 -1
View File
@@ -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 } = {}) {
+9 -5
View File
@@ -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([