test(browser): server stderr on failures, no orphan servers, waits on state
Final review fixes for the per-worker test servers (#274): - A failed test gets the stderr its server wrote during that test attached as `server-stderr`, so an aiohttp traceback behind a 500 reaches the CI report. The launcher keeps the newest 256 KB with a stream position per test (stderrMark/stderrSince), and the fixture attaches it only when the test did not end as expected. - The port announcement counts only as a complete line; a chunk ending mid-number was read as a shorter port. - server.py --exit-on-stdin-eof shuts down gracefully (database closed, temp root removed) when stdin closes. The launcher holds that pipe, so a SIGKILLed worker leaves no server behind, on every OS. - settled() gives up after 600 frames with the observation stats instead of hanging to the test timeout. - The Classic text-sync test waits for the page's 50 ms timer to install serializeValue instead of sleeping 60 ms. With that timer run 100 ms late (a scratch copy), the old wait failed 20/20 and the poll passed 20/20; the spec passed 240/240 at 8 workers x10 and 240/240 at 1 CPU with 4 workers. - The workflow test checks each npm cache key names its job's pinned image tag. Every new unit test failed before its change (missing export, the old regex timing out on the split announcement, an unknown server flag, a hung settle, a mismatched cache key).
This commit is contained in:
@@ -57,7 +57,8 @@ test('Classic keeps immediate selection, search query and metadata serialization
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
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 values=await page.evaluate(()=>{
|
||||||
const widget=node.widgets.find(item=>item.name==='text');
|
const widget=node.widgets.find(item=>item.name==='text');
|
||||||
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
// and database. `baseURL` (and so `page`, `request` and `browser.newContext`)
|
// and database. `baseURL` (and so `page`, `request` and `browser.newContext`)
|
||||||
// points at that worker's server. A server that dies fails the running test
|
// 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.
|
// 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 { 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;
|
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
|
||||||
|
|
||||||
@@ -17,9 +19,11 @@ export const test = base.extend({
|
|||||||
await supervisor.stop();
|
await supervisor.stop();
|
||||||
}
|
}
|
||||||
}, { scope: 'worker', timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
}, { scope: 'worker', timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||||
workerServer: [async ({ testServer }, use) => {
|
workerServer: [async ({ testServer }, use, testInfo) => {
|
||||||
const server = await testServer.ensure();
|
const server = await testServer.ensure();
|
||||||
|
const mark = server.stderrMark();
|
||||||
await use(server);
|
await use(server);
|
||||||
|
await attachServerStderr(testInfo, server, mark);
|
||||||
const crash = testServer.crash(server);
|
const crash = testServer.crash(server);
|
||||||
if (crash) throw crash;
|
if (crash) throw crash;
|
||||||
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||||
|
|||||||
+35
-1
@@ -1,6 +1,7 @@
|
|||||||
"""Serve production pages and API routes with disposable state for browser tests."""
|
"""Serve production pages and API routes with disposable state for browser tests."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -8,10 +9,12 @@ import socket
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import types
|
import types
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.web_runner import GracefulExit
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL.PngImagePlugin import PngInfo
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
|
||||||
@@ -540,17 +543,48 @@ def bind_listener(port: int) -> socket.socket:
|
|||||||
return listener
|
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:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--port", type=int, default=8765, help="0 binds an OS-chosen free port"
|
"--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)
|
args = parser.parse_args(argv)
|
||||||
listener = bind_listener(args.port)
|
listener = bind_listener(args.port)
|
||||||
# One machine-readable line tells the launcher which port this process holds.
|
# One machine-readable line tells the launcher which port this process holds.
|
||||||
print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True)
|
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:
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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)};},
|
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});
|
},{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();
|
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})=>{
|
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 request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||||
|
|||||||
@@ -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()
|
||||||
+93
-2
@@ -1,13 +1,52 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createServer } from 'node:net';
|
import { createServer } from 'node:net';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { spawn } from 'node:child_process';
|
||||||
import { startBrowserServer, createServerSupervisor } from '../../tools/vendor/start-browser-server.mjs';
|
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';
|
import { workflowWorkers } from '../../tools/vendor/test-workers.mjs';
|
||||||
|
|
||||||
const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url));
|
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 fake = (...extra) => ({ command: process.execPath, args: port => [fakeServer, '--port', String(port), ...extra] });
|
||||||
const REAL_SERVER_TIMEOUT_MS = 90_000;
|
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) {
|
function listen(port = 0) {
|
||||||
return new Promise((resolve, reject) => {
|
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');
|
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 => {
|
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();
|
const held = await listen();
|
||||||
t.after(() => held.close());
|
t.after(() => held.close());
|
||||||
|
|||||||
Vendored
+5
@@ -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], /^ {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[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`);
|
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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+21
-2
@@ -2,7 +2,9 @@
|
|||||||
// serves the readiness page and announces the bound port on stdout as
|
// serves the readiness page and announces the bound port on stdout as
|
||||||
// `PM_TEST_SERVER_PORT=<n>`, `--fail` writes to stderr and exits before
|
// `PM_TEST_SERVER_PORT=<n>`, `--fail` writes to stderr and exits before
|
||||||
// listening, and GET /__exit makes the process die unexpectedly after writing a
|
// 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';
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -12,6 +14,12 @@ if (args.includes('--fail')) {
|
|||||||
process.exit(4);
|
process.exit(4);
|
||||||
}
|
}
|
||||||
const server = createServer((request, response) => {
|
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') {
|
if (request.url === '/__exit') {
|
||||||
process.stderr.write('fake server: crashed on purpose\n');
|
process.stderr.write('fake server: crashed on purpose\n');
|
||||||
response.end('bye', () => process.exit(3));
|
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}`);
|
response.end(request.url === '/prompt_manager/test' ? 'ready' : `pid ${process.pid}`);
|
||||||
});
|
});
|
||||||
server.listen(port, '127.0.0.1', () => {
|
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)));
|
for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => server.close(() => process.exit(0)));
|
||||||
|
|||||||
+41
-8
@@ -10,11 +10,17 @@ const STOP_TIMEOUT_MS = 5_000;
|
|||||||
const POLL_INTERVAL_MS = 100;
|
const POLL_INTERVAL_MS = 100;
|
||||||
const PROBE_TIMEOUT_MS = 1_000;
|
const PROBE_TIMEOUT_MS = 1_000;
|
||||||
const STDERR_TAIL_BYTES = 8_192;
|
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';
|
const HOST = '127.0.0.1';
|
||||||
// tests/browser/server.py prints this once it holds its listening socket.
|
// tests/browser/server.py prints this once it holds its listening socket. Only a
|
||||||
const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\s*$/m;
|
// 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 }) {
|
function describeExit({ code, signal }) {
|
||||||
return signal ? `signal ${signal}` : `code ${code}`;
|
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
|
// 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
|
// itself and announce it on stdout, so nothing can take the port between
|
||||||
// choosing and binding. Resolves to {port, url, exited, exitStatus,
|
// choosing and binding. Resolves to {port, url, exited, exitStatus,
|
||||||
// stderrTail(), stop()}; rejects with the server's stderr tail when it exits
|
// pid, stderrTail(), stderrMark(), stderrSince(mark), stop()}; rejects with the
|
||||||
// or stays unready.
|
// 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({
|
export async function startBrowserServer({
|
||||||
port = 0,
|
port = 0,
|
||||||
command = process.env.PM_TEST_PYTHON || 'python',
|
command = process.env.PM_TEST_PYTHON || 'python',
|
||||||
args = pythonServer,
|
args = pythonServer,
|
||||||
cwd = repositoryRoot,
|
cwd = repositoryRoot,
|
||||||
timeoutMs = STARTUP_TIMEOUT_MS,
|
timeoutMs = STARTUP_TIMEOUT_MS,
|
||||||
|
stderrLogChars = STDERR_LOG_CHARS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||||
throw new Error(`startBrowserServer needs a port from 0 to 65535, got ${port}`);
|
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 stderr = '';
|
||||||
|
let stderrTotal = 0;
|
||||||
child.stderr.setEncoding('utf8');
|
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 stdout = '';
|
||||||
let announced = null;
|
let announced = null;
|
||||||
child.stdout.setEncoding('utf8');
|
child.stdout.setEncoding('utf8');
|
||||||
@@ -53,11 +67,21 @@ export async function startBrowserServer({
|
|||||||
const killOnExit = () => child.kill('SIGKILL');
|
const killOnExit = () => child.kill('SIGKILL');
|
||||||
process.once('exit', killOnExit);
|
process.once('exit', killOnExit);
|
||||||
const server = {
|
const server = {
|
||||||
|
pid: child.pid,
|
||||||
port: port || null,
|
port: port || null,
|
||||||
url: port ? `http://${HOST}:${port}` : null,
|
url: port ? `http://${HOST}:${port}` : null,
|
||||||
exitStatus: null,
|
exitStatus: null,
|
||||||
stopping: false,
|
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 => {
|
exited: new Promise(done => {
|
||||||
child.once('error', error => {
|
child.once('error', error => {
|
||||||
stderr += `\n${error.message}`;
|
stderr += `\n${error.message}`;
|
||||||
@@ -71,6 +95,7 @@ export async function startBrowserServer({
|
|||||||
}),
|
}),
|
||||||
async stop() {
|
async stop() {
|
||||||
server.stopping = true;
|
server.stopping = true;
|
||||||
|
child.stdin.destroy();
|
||||||
if (server.exitStatus === null) {
|
if (server.exitStatus === null) {
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
const stopped = await Promise.race([server.exited, delay(STOP_TIMEOUT_MS).then(() => false)]);
|
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`);
|
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
|
// 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
|
// 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
|
// reported, not retried); crash(server) turns an unexpected exit into an error
|
||||||
|
|||||||
Reference in New Issue
Block a user