diff --git a/tests/browser/classic-node.spec.mjs b/tests/browser/classic-node.spec.mjs index 9eb6683..f416062 100644 --- a/tests/browser/classic-node.spec.mjs +++ b/tests/browser/classic-node.spec.mjs @@ -57,7 +57,8 @@ test('Classic keeps immediate selection, search query and metadata serialization }); test('Classic detaches native text synchronization on removal',async({page})=>{ - await page.waitForTimeout(60); + // The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time. + await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function'); const values=await page.evaluate(()=>{ const widget=node.widgets.find(item=>item.name==='text'); widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input')); diff --git a/tests/browser/fixtures.mjs b/tests/browser/fixtures.mjs index c1173f5..511e512 100644 --- a/tests/browser/fixtures.mjs +++ b/tests/browser/fixtures.mjs @@ -3,8 +3,10 @@ // and database. `baseURL` (and so `page`, `request` and `browser.newContext`) // points at that worker's server. A server that dies fails the running test // with its stderr tail, and the next test in the worker gets a fresh server. +// A test that fails gets the stderr its server wrote during it attached as +// `server-stderr`. import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; -import { createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs'; +import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs'; const SERVER_FIXTURE_TIMEOUT_MS = 90_000; @@ -17,9 +19,11 @@ export const test = base.extend({ await supervisor.stop(); } }, { scope: 'worker', timeout: SERVER_FIXTURE_TIMEOUT_MS }], - workerServer: [async ({ testServer }, use) => { + workerServer: [async ({ testServer }, use, testInfo) => { const server = await testServer.ensure(); + const mark = server.stderrMark(); await use(server); + await attachServerStderr(testInfo, server, mark); const crash = testServer.crash(server); if (crash) throw crash; }, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }], diff --git a/tests/browser/server.py b/tests/browser/server.py index 599d92e..c446ed0 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -1,6 +1,7 @@ """Serve production pages and API routes with disposable state for browser tests.""" import argparse +import asyncio import json import os import shutil @@ -8,10 +9,12 @@ import socket from pathlib import Path import sys import tempfile +import threading import time import types from aiohttp import web +from aiohttp.web_runner import GracefulExit from PIL import Image from PIL.PngImagePlugin import PngInfo @@ -540,17 +543,48 @@ def bind_listener(port: int) -> socket.socket: return listener +def _graceful_exit() -> None: + raise GracefulExit() + + +def exit_when_stdin_closes(loop: asyncio.AbstractEventLoop) -> threading.Thread: + """Stop the app gracefully once stdin reaches end of file. + + The launcher keeps the write end of this process's stdin pipe, and the OS + closes it however the launcher dies, SIGKILL included. GracefulExit runs + aiohttp's cleanup (closing the database) and lets the temp root be removed, + on every OS, where a signal from a dead parent would not. + """ + + def watch() -> None: + while sys.stdin.buffer.read(65536): + pass + loop.call_soon_threadsafe(_graceful_exit) + + thread = threading.Thread(target=watch, name="stdin-eof", daemon=True) + thread.start() + return thread + + def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser() parser.add_argument( "--port", type=int, default=8765, help="0 binds an OS-chosen free port" ) + parser.add_argument( + "--exit-on-stdin-eof", + action="store_true", + help="shut down when stdin closes (the launcher's parent-death guard)", + ) args = parser.parse_args(argv) listener = bind_listener(args.port) # One machine-readable line tells the launcher which port this process holds. print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True) + loop = asyncio.new_event_loop() + if args.exit_on_stdin_eof: + exit_when_stdin_closes(loop) with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory: - web.run_app(create_app(Path(directory).resolve()), sock=listener) + web.run_app(create_app(Path(directory).resolve()), sock=listener, loop=loop) if __name__ == "__main__": diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 1b830dd..85a9ca3 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -382,7 +382,9 @@ async function observeLibrary(page, hold=false) { // Resolves once two consecutive rendering frames find no Library frame queued. Each check runs inside a native // frame callback, so a frame the page queues later in that update (a scroll event, a ResizeObserver delivery) is // still pending there. A timer is not a frame: under CPU load a 60 ms wait could end before the scroll's frame. - settled(){return new Promise(resolve=>{let quiet=0;const check=()=>{quiet=pending.size?0:quiet+1;if(quiet>=2)resolve();else raf(check);};raf(check);});}, + // A frame that never drains (a rAF loop, or settling while frames are held) fails after `limit` frames with the stats. + settled(limit=600){return new Promise((resolve,reject)=>{let quiet=0,frames=0;const check=()=>{frames++;quiet=pending.size?0:quiet+1; + if(quiet>=2)resolve();else if(frames>=limit)reject(new Error(`Library never settled within ${limit} frames: ${JSON.stringify(window.libraryObservation.stats())}`));else raf(check);};raf(check);});}, stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};}, }; },{hold}); @@ -688,6 +690,17 @@ test('title and Explore history are buttons that open history',async({page,reque await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused(); }); +test('settling fails with the observation stats when a Library frame stays queued',async({page,request})=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page); + await page.evaluate(()=>{window.libraryObservation.holdFrames();dispatchEvent(new Event('scroll'));}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + const failure=await page.evaluate(()=>window.libraryObservation.settled(30).then(()=>'settled',error=>error.message)); + expect(failure).toMatch(/^Library never settled within 30 frames: \{.*"heldFrames":[1-9]/); + await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page); +}); + test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{ await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); diff --git a/tests/test_browser_server_lifetime.py b/tests/test_browser_server_lifetime.py new file mode 100644 index 0000000..1380ed5 --- /dev/null +++ b/tests/test_browser_server_lifetime.py @@ -0,0 +1,77 @@ +"""The page-workflow test server ends with its launcher (#274 review M2). + +A Playwright worker that is SIGKILLed never runs its fixture teardown, so the +server it started must notice on its own: with ``--exit-on-stdin-eof`` it shuts +down when the launcher's end of its stdin pipe closes, and still removes its +temp root. This runs on every OS in CI, where Windows is the strict case. +""" + +import os +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SERVER_TIMEOUT_S = 60 + + +class TestBrowserServerLifetime(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def start(self, *extra): + env = {**os.environ, "TMPDIR": self.tmp.name} + env.update(TEMP=self.tmp.name, TMP=self.tmp.name) + process = subprocess.Popen( + [sys.executable, "-m", "tests.browser.server", "--port", "0", *extra], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def stop(): + if process.poll() is None: + process.kill() + process.communicate(timeout=SERVER_TIMEOUT_S) + + self.addCleanup(stop) + line = process.stdout.readline().decode() + self.assertTrue(line.startswith("PM_TEST_SERVER_PORT="), line) + url = f"http://127.0.0.1:{int(line.split('=', 1)[1])}/prompt_manager/test" + deadline = time.monotonic() + SERVER_TIMEOUT_S + while True: + try: + with urllib.request.urlopen(url, timeout=1) as response: # nosec B310 + self.assertEqual(response.status, 200) + return process + except OSError: + self.assertIsNone(process.poll(), "the server exited early") + self.assertLess(time.monotonic(), deadline, "never ready") + time.sleep(0.1) + + def roots(self): + return sorted(path.name for path in Path(self.tmp.name).glob("pm-browser-*")) + + def test_server_exits_and_removes_its_temp_root_when_stdin_closes(self): + process = self.start("--exit-on-stdin-eof") + self.assertEqual(len(self.roots()), 1, "the server works in its own temp root") + process.stdin.close() + self.assertEqual(process.wait(timeout=SERVER_TIMEOUT_S), 0) + self.assertEqual(self.roots(), [], "the temp root and its database are removed") + + def test_server_keeps_running_with_stdin_closed_without_the_flag(self): + process = self.start() + process.stdin.close() + with self.assertRaises(subprocess.TimeoutExpired): + process.wait(timeout=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vendor/browser-server-fixture.test.mjs b/tests/vendor/browser-server-fixture.test.mjs index 3c4076f..aa8c3e1 100644 --- a/tests/vendor/browser-server-fixture.test.mjs +++ b/tests/vendor/browser-server-fixture.test.mjs @@ -1,13 +1,52 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:net'; -import { fileURLToPath } from 'node:url'; -import { startBrowserServer, createServerSupervisor } from '../../tools/vendor/start-browser-server.mjs'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { startBrowserServer, createServerSupervisor, attachServerStderr } from '../../tools/vendor/start-browser-server.mjs'; import { workflowWorkers } from '../../tools/vendor/test-workers.mjs'; const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url)); const fake = (...extra) => ({ command: process.execPath, args: port => [fakeServer, '--port', String(port), ...extra] }); const REAL_SERVER_TIMEOUT_MS = 90_000; +const launcher = fileURLToPath(new URL('../../tools/vendor/start-browser-server.mjs', import.meta.url)); + +/** Polls `check` until it is truthy, failing with `what` after `ms`. */ +async function waitFor(check, what, ms = 5_000) { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await delay(20); + } +} + +async function writeStderr(server, text) { + await (await fetch(`${server.url}/__stderr?text=${encodeURIComponent(text)}`)).text(); + await waitFor(() => server.stderrTail().endsWith(text.trimEnd()), `stderr ${JSON.stringify(text)}`); +} + +const alive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } }; + +/** Starts a server from a separate Node worker process, SIGKILLs that worker and returns the server's pid. */ +async function serverOfKilledWorker(options) { + const script = `import { startBrowserServer } from ${JSON.stringify(pathToFileURL(launcher).href)}; + const server = await startBrowserServer(${options}); + console.log('SERVER_PID=' + server.pid); + setInterval(() => {}, 1000);`; + const worker = spawn(process.execPath, ['--input-type=module', '-e', script], { stdio: ['ignore', 'pipe', 'inherit'] }); + let out = ''; + worker.stdout.setEncoding('utf8'); + worker.stdout.on('data', chunk => { out += chunk; }); + await waitFor(() => /SERVER_PID=\d+\n/.test(out) || worker.exitCode !== null, 'the worker to start its server', REAL_SERVER_TIMEOUT_MS); + const pid = Number(/SERVER_PID=(\d+)/.exec(out)?.[1]); + assert.ok(pid > 0, `the worker started a server (output: ${out})`); + assert.ok(alive(pid), 'the server runs while its worker lives'); + worker.kill('SIGKILL'); + await once(worker, 'exit'); + return pid; +} function listen(port = 0) { return new Promise((resolve, reject) => { @@ -107,6 +146,58 @@ test('supervisor reports a startup failure once and tries again on the next ensu assert.equal(await (await fetch(`${server.url}/prompt_manager/test`)).text(), 'ready'); }); +test('the port announcement counts only once its line is complete', async t => { + const server = await startBrowserServer({ ...fake('--split-announce'), timeoutMs: 5_000 }); + t.after(() => server.stop()); + assert.equal(await (await fetch(`${server.url}/prompt_manager/test`)).text(), 'ready'); + assert.equal(server.url, `http://127.0.0.1:${server.port}`); +}); + +test('server stderr since a mark holds only what came after it, bounded to the newest output', async t => { + const server = await startBrowserServer({ ...fake(), stderrLogChars: 64 }); + t.after(() => server.stop()); + await writeStderr(server, 'earlier test\n'); + const mark = server.stderrMark(); + assert.equal(server.stderrSince(mark), ''); + await writeStderr(server, 'Traceback: this test\n'); + assert.equal(server.stderrSince(mark), 'Traceback: this test\n'); + const flood = `${'x'.repeat(80)}newest\n`; + await writeStderr(server, flood); + const kept = server.stderrSince(mark); + assert.match(kept, /^\[earlier server stderr dropped\]\n/); + assert.ok(kept.endsWith('newest\n'), 'the newest output is kept'); + assert.ok(kept.length <= 64 + '[earlier server stderr dropped]\n'.length, `bounded, got ${kept.length} chars`); +}); + +test('a failed test gets the server stderr written during it attached; a passing one does not', async t => { + const server = await startBrowserServer(fake()); + t.after(() => server.stop()); + const attached = []; + const info = status => ({ status, expectedStatus: 'passed', attach: async (name, options) => { attached.push({ name, ...options }); } }); + await writeStderr(server, 'before the test\n'); + const mark = server.stderrMark(); + await attachServerStderr(info('failed'), server, mark); + assert.deepEqual(attached, [], 'nothing to attach when the server wrote nothing'); + await writeStderr(server, 'Traceback (most recent call last): boom\n'); + await attachServerStderr(info('passed'), server, mark); + assert.deepEqual(attached, [], 'a passing test attaches nothing'); + await attachServerStderr(info('timedOut'), server, mark); + assert.equal(attached.length, 1); + assert.equal(attached[0].name, 'server-stderr'); + assert.equal(attached[0].contentType, 'text/plain'); + assert.equal(String(attached[0].body), 'Traceback (most recent call last): boom\n'); +}); + +test('a server exits with its worker even when the worker is SIGKILLed', async () => { + const pid = await serverOfKilledWorker(`{ command: process.execPath, args: port => [${JSON.stringify(fakeServer)}, '--port', String(port), '--exit-on-stdin-eof'] }`); + await waitFor(() => !alive(pid), `server ${pid} to exit after its worker died`); +}); + +test('tests/browser/server.py exits with a SIGKILLed worker', { timeout: 2 * REAL_SERVER_TIMEOUT_MS }, async () => { + const pid = await serverOfKilledWorker(`{ timeoutMs: ${REAL_SERVER_TIMEOUT_MS} }`); + await waitFor(() => !alive(pid), `server.py ${pid} to exit after its worker died`, 15_000); +}); + test('tests/browser/server.py binds an OS-chosen port for --port 0 and still honours --port N', { timeout: 2 * REAL_SERVER_TIMEOUT_MS }, async t => { const held = await listen(); t.after(() => held.close()); diff --git a/tests/vendor/ci-workflow.test.mjs b/tests/vendor/ci-workflow.test.mjs index 2c8f3d3..b2d8064 100644 --- a/tests/vendor/ci-workflow.test.mjs +++ b/tests/vendor/ci-workflow.test.mjs @@ -207,6 +207,11 @@ test('every npm ci restores the npm download cache keyed on the vendor lockfile assert.match(jobSteps[cacheAt], /^ {10}path: ~\/\.npm$/m, `${id} caches npm's download cache, not node_modules`); assert.match(jobSteps[cacheAt], /hashFiles\('tools\/vendor\/package-lock\.json'\)/, `${id} keys the cache on the lockfile`); assert.match(jobSteps[installAt], /npm ci --prefix tools\/vendor --ignore-scripts/, `${id} still installs without scripts`); + // The key names the job's own pinned image, so an image bump starts a fresh cache. + const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble$/m.exec(body)?.[1]; + assert.ok(image, `${id} runs in the pinned Playwright image`); + assert.match(jobSteps[cacheAt], new RegExp(`key: npm-\\$\\{\\{ runner\\.os \\}\\}-playwright-${image.replaceAll('.', '\\.')}-\\$\\{\\{ hashFiles`), + `${id} keys the cache on its image ${image}`); } }); diff --git a/tests/vendor/fake-browser-server.mjs b/tests/vendor/fake-browser-server.mjs index 0f14322..827b174 100644 --- a/tests/vendor/fake-browser-server.mjs +++ b/tests/vendor/fake-browser-server.mjs @@ -2,7 +2,9 @@ // serves the readiness page and announces the bound port on stdout as // `PM_TEST_SERVER_PORT=`, `--fail` writes to stderr and exits before // listening, and GET /__exit makes the process die unexpectedly after writing a -// last stderr line. +// last stderr line. GET /__stderr?text=T writes T to stderr before answering. +// `--split-announce` writes the announcement in two chunks, the first ending +// mid-number. `--exit-on-stdin-eof` exits when its stdin closes, as server.py does. import { createServer } from 'node:http'; const args = process.argv.slice(2); @@ -12,6 +14,12 @@ if (args.includes('--fail')) { process.exit(4); } const server = createServer((request, response) => { + const url = new URL(request.url, 'http://fake'); + if (url.pathname === '/__stderr') { + process.stderr.write(url.searchParams.get('text')); + response.end('written'); + return; + } if (request.url === '/__exit') { process.stderr.write('fake server: crashed on purpose\n'); response.end('bye', () => process.exit(3)); @@ -20,6 +28,17 @@ const server = createServer((request, response) => { response.end(request.url === '/prompt_manager/test' ? 'ready' : `pid ${process.pid}`); }); server.listen(port, '127.0.0.1', () => { - process.stdout.write(`fake server: listening\nPM_TEST_SERVER_PORT=${server.address().port}\n`); + const line = `PM_TEST_SERVER_PORT=${server.address().port}\n`; + if (!args.includes('--split-announce')) { + process.stdout.write(`fake server: listening\n${line}`); + return; + } + // The first chunk is a complete-looking number, one digit short of the real port. + process.stdout.write(`fake server: listening\n${line.slice(0, -2)}`); + setTimeout(() => process.stdout.write(line.slice(-2)), 300); }); +if (args.includes('--exit-on-stdin-eof')) { + process.stdin.resume(); + process.stdin.once('end', () => process.exit(0)); +} for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => server.close(() => process.exit(0))); diff --git a/tools/vendor/start-browser-server.mjs b/tools/vendor/start-browser-server.mjs index b0c7f6b..f307016 100644 --- a/tools/vendor/start-browser-server.mjs +++ b/tools/vendor/start-browser-server.mjs @@ -10,11 +10,17 @@ const STOP_TIMEOUT_MS = 5_000; const POLL_INTERVAL_MS = 100; const PROBE_TIMEOUT_MS = 1_000; const STDERR_TAIL_BYTES = 8_192; +// Server stderr kept for the failing test's attachment; the newest output wins. +const STDERR_LOG_CHARS = 256 * 1024; +const STDERR_DROPPED = '[earlier server stderr dropped]\n'; const HOST = '127.0.0.1'; -// tests/browser/server.py prints this once it holds its listening socket. -const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\s*$/m; +// tests/browser/server.py prints this once it holds its listening socket. Only a +// complete line counts: a chunk may end mid-number. +const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\r?\n/m; -const pythonServer = port => ['-m', 'tests.browser.server', '--port', String(port)]; +// The server exits when its stdin closes, so a worker that dies without teardown +// (SIGKILL) leaves no server behind; the launcher holds the pipe's other end. +const pythonServer = port => ['-m', 'tests.browser.server', '--port', String(port), '--exit-on-stdin-eof']; function describeExit({ code, signal }) { return signal ? `signal ${signal}` : `code ${code}`; @@ -24,22 +30,30 @@ function describeExit({ code, signal }) { // readiness page. `port` 0 (the default) lets the server bind an OS-chosen port // itself and announce it on stdout, so nothing can take the port between // choosing and binding. Resolves to {port, url, exited, exitStatus, -// stderrTail(), stop()}; rejects with the server's stderr tail when it exits -// or stays unready. +// pid, stderrTail(), stderrMark(), stderrSince(mark), stop()}; rejects with the +// server's stderr tail when it exits or stays unready. stdin is a pipe this +// process holds open for the server's life. export async function startBrowserServer({ port = 0, command = process.env.PM_TEST_PYTHON || 'python', args = pythonServer, cwd = repositoryRoot, timeoutMs = STARTUP_TIMEOUT_MS, + stderrLogChars = STDERR_LOG_CHARS, } = {}) { if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error(`startBrowserServer needs a port from 0 to 65535, got ${port}`); } - const child = spawn(command, args(port), { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(command, args(port), { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + // Never written; closing it (or this process dying) tells the server to exit. + child.stdin.on('error', () => {}); let stderr = ''; + let stderrTotal = 0; child.stderr.setEncoding('utf8'); - child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-STDERR_TAIL_BYTES); }); + child.stderr.on('data', chunk => { + stderr = (stderr + chunk).slice(-Math.max(stderrLogChars, STDERR_TAIL_BYTES)); + stderrTotal += chunk.length; + }); let stdout = ''; let announced = null; child.stdout.setEncoding('utf8'); @@ -53,11 +67,21 @@ export async function startBrowserServer({ const killOnExit = () => child.kill('SIGKILL'); process.once('exit', killOnExit); const server = { + pid: child.pid, port: port || null, url: port ? `http://${HOST}:${port}` : null, exitStatus: null, stopping: false, - stderrTail: () => stderr.trimEnd(), + stderrTail: () => stderr.slice(-STDERR_TAIL_BYTES).trimEnd(), + // A position in the server's stderr stream, for stderrSince(). + stderrMark: () => stderrTotal, + // What the server wrote after `mark`, at most stderrLogChars of the newest of it. + stderrSince(mark) { + const kept = Math.min(stderr.length, stderrLogChars); + const keptFrom = stderrTotal - kept; + const log = stderr.slice(stderr.length - kept); + return mark >= keptFrom ? log.slice(mark - keptFrom) : STDERR_DROPPED + log; + }, exited: new Promise(done => { child.once('error', error => { stderr += `\n${error.message}`; @@ -71,6 +95,7 @@ export async function startBrowserServer({ }), async stop() { server.stopping = true; + child.stdin.destroy(); if (server.exitStatus === null) { child.kill('SIGTERM'); const stopped = await Promise.race([server.exited, delay(STOP_TIMEOUT_MS).then(() => false)]); @@ -117,6 +142,14 @@ export async function startBrowserServer({ throw await failure(`was not ready at ${READY_PATH} within ${timeoutMs} ms`); } +// After a test: when it did not end as expected, attach what the server wrote to +// stderr since `mark` (an aiohttp traceback names the cause a "got 500" hides). +export async function attachServerStderr(testInfo, server, mark) { + if (testInfo.status === testInfo.expectedStatus) return; + const log = server.stderrSince(mark); + if (log) await testInfo.attach('server-stderr', { body: log, contentType: 'text/plain' }); +} + // Keep one live server for a Playwright worker. ensure() returns the running // server, or starts a fresh one when the last one died (a failed start is // reported, not retried); crash(server) turns an unexpected exit into an error