0.2.3: make browser verification cleanup explicit
This commit is contained in:
commit
314564c139
4 files changed
+78
-6
No files matched your search
@@ -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。测试只在主动停服至恢复握手的窗口内排除这些已知传输日志;正常运行期间仍检查它们,脚本异常始终保留,所有版本指纹、重连及换局刷新断言不变。
|
||||
@@ -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
@@ -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 } = {}) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user