@hydranium/core 1.0.0-next.51 → 1.0.0-next.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+ /**
10
+ * A TCP proxy that can half-close a connection, so an e2e suite can reproduce a
11
+ * flaky network against a real browser session.
12
+ *
13
+ * Toggling the browser offline is not enough. On localhost the server sees the
14
+ * close immediately and reaps the socket before the browser reconnects, so the
15
+ * window where both sides disagree about the session — the one where reconnect
16
+ * defects live — never opens. Reaching it needs the browser's socket to die
17
+ * while the server still believes it is alive, and nothing inside the app or in
18
+ * Playwright's offline emulation can produce that asymmetry: both close cleanly
19
+ * at both ends.
20
+ *
21
+ * The proxy holds both halves of every connection and lets a test kill them
22
+ * independently:
23
+ *
24
+ * `break` destroy the BROWSER-side sockets, keep the server-side ones open.
25
+ * The browser reconnects through a fresh pair while the server
26
+ * still holds the old one.
27
+ * `release` destroy the orphaned server-side sockets `break` left. The
28
+ * server's disconnect handler fires at once, rather than whenever
29
+ * its next heartbeat write would have failed.
30
+ */
31
+ /** Where the proxy listens, and the application it fronts. */
32
+ export interface FlakyNetworkProxyOptions {
33
+ /** Port the browser connects to. */
34
+ readonly listenPort: number;
35
+ /** Port of the application being proxied. */
36
+ readonly targetPort: number;
37
+ /** Host of the application being proxied. Defaults to loopback. */
38
+ readonly targetHost?: string;
39
+ /** Port the HTTP control channel listens on. Defaults to `listenPort + 1000`. */
40
+ readonly controlPort?: number;
41
+ }
42
+ /** The commands the control channel accepts, and the proxy's own vocabulary. */
43
+ export type FlakyNetworkCommand = 'break' | 'release' | 'status';
44
+ /** Drives a running proxy. Implemented both in-process and over HTTP. */
45
+ export interface FlakyNetworkControl {
46
+ /** Kill the browser side of every live connection; leave the server side up. */
47
+ break(): Promise<string>;
48
+ /** Kill the server side of every connection `break` orphaned. */
49
+ release(): Promise<string>;
50
+ /** How many connections are live and how many are orphaned. */
51
+ status(): Promise<string>;
52
+ }
53
+ export interface FlakyNetworkProxy extends FlakyNetworkControl {
54
+ /** Base URL a browser should be pointed at, i.e. Playwright's `baseURL`. */
55
+ readonly url: string;
56
+ /** Control channel, for workers that cannot reach this object directly. */
57
+ readonly controlUrl: string;
58
+ close(): Promise<void>;
59
+ }
60
+ /**
61
+ * Starts the proxy in the calling process.
62
+ *
63
+ * Call it from a Playwright config, which runs in the main process before any
64
+ * worker forks. Workers are separate processes and cannot reach the returned
65
+ * object, which is what {@link controlUrl} and {@link flakyNetworkControlClient}
66
+ * are for.
67
+ */
68
+ export declare function startFlakyNetworkProxy(options: FlakyNetworkProxyOptions): Promise<FlakyNetworkProxy>;
69
+ /**
70
+ * Drives a proxy running in another process, over its control channel.
71
+ *
72
+ * This is what a spec uses: Playwright workers are separate processes from the
73
+ * config that started the proxy.
74
+ */
75
+ export declare function flakyNetworkControlClient(controlUrl: string): FlakyNetworkControl;
76
+ //# sourceMappingURL=flaky-network-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flaky-network-proxy.d.ts","sourceRoot":"","sources":["../../../src/testing/playwright/flaky-network-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAKlF;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,8DAA8D;AAC9D,MAAM,WAAW,wBAAwB;IACtC,oCAAoC;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,6CAA6C;IAC7C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,mEAAmE;IACnE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,gFAAgF;AAChF,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEjE,yEAAyE;AACzE,MAAM,WAAW,mBAAmB;IACjC,gFAAgF;IAChF,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACzB,iEAAiE;IACjE,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3B,+DAA+D;IAC/D,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAkB,SAAQ,mBAAmB;IAC3D,4EAA4E;IAC5E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AASD;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAyH1G;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,mBAAmB,CAajF"}
@@ -0,0 +1,164 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+ import { createServer as createHttpServer } from 'node:http';
10
+ import { connect, createServer as createTcpServer } from 'node:net';
11
+ /**
12
+ * Starts the proxy in the calling process.
13
+ *
14
+ * Call it from a Playwright config, which runs in the main process before any
15
+ * worker forks. Workers are separate processes and cannot reach the returned
16
+ * object, which is what {@link controlUrl} and {@link flakyNetworkControlClient}
17
+ * are for.
18
+ */
19
+ export async function startFlakyNetworkProxy(options) {
20
+ const targetHost = options.targetHost ?? '127.0.0.1';
21
+ const controlPort = options.controlPort ?? options.listenPort + 1000;
22
+ const pairs = new Set();
23
+ let sequence = 0;
24
+ let transcript = [];
25
+ const log = (message) => {
26
+ transcript.push(message);
27
+ console.log(`[flaky-network] ${message}`);
28
+ };
29
+ const proxyServer = createTcpServer(browserSocket => {
30
+ const id = ++sequence;
31
+ const targetSocket = connect(options.targetPort, targetHost);
32
+ const pair = { id, browserSocket, targetSocket, orphaned: false };
33
+ pairs.add(pair);
34
+ // Forwarded by hand rather than with `pipe()`, so killing one side does not
35
+ // tear the other down — which is the entire point of the proxy.
36
+ browserSocket.on('data', chunk => {
37
+ if (!targetSocket.destroyed) {
38
+ targetSocket.write(chunk);
39
+ }
40
+ });
41
+ targetSocket.on('data', chunk => {
42
+ if (!pair.orphaned && !browserSocket.destroyed) {
43
+ browserSocket.write(chunk);
44
+ }
45
+ });
46
+ const teardown = (origin) => {
47
+ if (pair.orphaned && origin === 'browser') {
48
+ // Expected: `break` killed this side deliberately. The server side
49
+ // stays up until `release`.
50
+ return;
51
+ }
52
+ pairs.delete(pair);
53
+ browserSocket.destroy();
54
+ targetSocket.destroy();
55
+ };
56
+ browserSocket.on('close', () => teardown('browser'));
57
+ targetSocket.on('close', () => teardown('target'));
58
+ // A half-killed pair reliably raises ECONNRESET on the surviving side; an
59
+ // unhandled 'error' would take the whole runner down with it.
60
+ browserSocket.on('error', () => undefined);
61
+ targetSocket.on('error', () => undefined);
62
+ });
63
+ const commands = {
64
+ break: () => {
65
+ const live = [...pairs].filter(pair => !pair.orphaned);
66
+ if (live.length === 0) {
67
+ log('nothing to break');
68
+ return;
69
+ }
70
+ for (const pair of live) {
71
+ pair.orphaned = true;
72
+ pair.browserSocket.destroy();
73
+ }
74
+ log(`${live.length} connection(s) broken; the browser should reconnect shortly`);
75
+ },
76
+ release: () => {
77
+ const orphans = [...pairs].filter(pair => pair.orphaned);
78
+ if (orphans.length === 0) {
79
+ log('no orphaned connections; run `break` first');
80
+ return;
81
+ }
82
+ for (const pair of orphans) {
83
+ pairs.delete(pair);
84
+ pair.targetSocket.destroy();
85
+ }
86
+ log(`${orphans.length} orphaned connection(s) destroyed; their late disconnect fires now`);
87
+ },
88
+ status: () => {
89
+ const live = [...pairs].filter(pair => !pair.orphaned).length;
90
+ const orphaned = pairs.size - live;
91
+ log(`${live} live, ${orphaned} orphaned`);
92
+ }
93
+ };
94
+ const run = (name) => {
95
+ transcript = [];
96
+ const command = commands[name];
97
+ if (command) {
98
+ command();
99
+ }
100
+ else {
101
+ log(`unknown command '${name}'; use: ${Object.keys(commands).join(' | ')}`);
102
+ }
103
+ return transcript.join('\n');
104
+ };
105
+ const controlServer = createHttpServer((request, response) => {
106
+ const output = run((request.url ?? '').replace(/^\//, '').split('?')[0]);
107
+ response.writeHead(200, { 'content-type': 'text/plain' });
108
+ response.end(`${output}\n`);
109
+ });
110
+ await Promise.all([listen(proxyServer, options.listenPort), listen(controlServer, controlPort)]);
111
+ // Neither server keeps the process alive, so a runner that starts one from a
112
+ // config does not need a teardown hook to exit — and a caller that wants a
113
+ // deterministic shutdown still has `close()`.
114
+ proxyServer.unref();
115
+ controlServer.unref();
116
+ return {
117
+ url: `http://localhost:${options.listenPort}`,
118
+ controlUrl: `http://localhost:${controlPort}`,
119
+ break: async () => run('break'),
120
+ release: async () => run('release'),
121
+ status: async () => run('status'),
122
+ close: async () => {
123
+ for (const pair of pairs) {
124
+ pair.browserSocket.destroy();
125
+ pair.targetSocket.destroy();
126
+ }
127
+ pairs.clear();
128
+ await Promise.all([close(proxyServer), close(controlServer)]);
129
+ }
130
+ };
131
+ }
132
+ /**
133
+ * Drives a proxy running in another process, over its control channel.
134
+ *
135
+ * This is what a spec uses: Playwright workers are separate processes from the
136
+ * config that started the proxy.
137
+ */
138
+ export function flakyNetworkControlClient(controlUrl) {
139
+ const send = async (command) => {
140
+ const response = await fetch(`${controlUrl}/${command}`);
141
+ if (!response.ok) {
142
+ throw new Error(`flaky-network proxy rejected '${command}': ${response.status}`);
143
+ }
144
+ return (await response.text()).trim();
145
+ };
146
+ return {
147
+ break: () => send('break'),
148
+ release: () => send('release'),
149
+ status: () => send('status')
150
+ };
151
+ }
152
+ function listen(server, port) {
153
+ return new Promise((resolve, reject) => {
154
+ server.once('error', reject);
155
+ server.listen(port, () => {
156
+ server.off('error', reject);
157
+ resolve();
158
+ });
159
+ });
160
+ }
161
+ function close(server) {
162
+ return new Promise(resolve => server.close(() => resolve()));
163
+ }
164
+ //# sourceMappingURL=flaky-network-proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flaky-network-proxy.js","sourceRoot":"","sources":["../../../src/testing/playwright/flaky-network-proxy.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
  export * from './e2e-profiling.js';
10
+ export * from './flaky-network-proxy.js';
10
11
  export * from './browser-capture-bridge.js';
11
12
  export * from './server-log-capture.js';
12
13
  export * from './server-log-fixture.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/testing/playwright/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAKlF,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/testing/playwright/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAKlF,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC"}
@@ -10,6 +10,7 @@
10
10
  // `@hydranium/core/testing` barrel) so importing the framework's unit-test
11
11
  // helpers never pulls in `@playwright/test` — which is an OPTIONAL peer here.
12
12
  export * from './e2e-profiling.js';
13
+ export * from './flaky-network-proxy.js';
13
14
  export * from './browser-capture-bridge.js';
14
15
  export * from './server-log-capture.js';
15
16
  export * from './server-log-fixture.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/testing/playwright/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/testing/playwright/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/core",
3
- "version": "1.0.0-next.51",
3
+ "version": "1.0.0-next.52",
4
4
  "description": "Foundational runtime of the hydranium framework: AST coordination layer (services, integrity, AST extension, serialization, multi-client documents) plus the LSP textual head at the /lsp subpath. Consumed by protocol-head packages (@hydranium/data-server, @hydranium/glsp-server).",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -104,8 +104,8 @@
104
104
  "diff": "^5.2.0"
105
105
  },
106
106
  "devDependencies": {
107
- "@hydranium/langium": "1.0.0-next.51",
108
- "@hydranium/protocol": "1.0.0-next.51",
107
+ "@hydranium/langium": "1.0.0-next.52",
108
+ "@hydranium/protocol": "1.0.0-next.52",
109
109
  "@playwright/test": "^1.40.0",
110
110
  "@types/diff": "^5.2.0",
111
111
  "rimraf": "^5.0.0",
@@ -117,8 +117,8 @@
117
117
  "vscode-languageserver-types": "^3.17.5"
118
118
  },
119
119
  "peerDependencies": {
120
- "@hydranium/langium": "1.0.0-next.51",
121
- "@hydranium/protocol": "1.0.0-next.51",
120
+ "@hydranium/langium": "1.0.0-next.52",
121
+ "@hydranium/protocol": "1.0.0-next.52",
122
122
  "@playwright/test": "^1.40.0",
123
123
  "vscode-jsonrpc": "9.0.1",
124
124
  "vscode-languageserver": "~10.0.1",
@@ -0,0 +1,240 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
11
+ import { connect, createServer as createTcpServer, type Server as TcpServer, type Socket } from 'node:net';
12
+
13
+ /**
14
+ * A TCP proxy that can half-close a connection, so an e2e suite can reproduce a
15
+ * flaky network against a real browser session.
16
+ *
17
+ * Toggling the browser offline is not enough. On localhost the server sees the
18
+ * close immediately and reaps the socket before the browser reconnects, so the
19
+ * window where both sides disagree about the session — the one where reconnect
20
+ * defects live — never opens. Reaching it needs the browser's socket to die
21
+ * while the server still believes it is alive, and nothing inside the app or in
22
+ * Playwright's offline emulation can produce that asymmetry: both close cleanly
23
+ * at both ends.
24
+ *
25
+ * The proxy holds both halves of every connection and lets a test kill them
26
+ * independently:
27
+ *
28
+ * `break` destroy the BROWSER-side sockets, keep the server-side ones open.
29
+ * The browser reconnects through a fresh pair while the server
30
+ * still holds the old one.
31
+ * `release` destroy the orphaned server-side sockets `break` left. The
32
+ * server's disconnect handler fires at once, rather than whenever
33
+ * its next heartbeat write would have failed.
34
+ */
35
+
36
+ /** Where the proxy listens, and the application it fronts. */
37
+ export interface FlakyNetworkProxyOptions {
38
+ /** Port the browser connects to. */
39
+ readonly listenPort: number;
40
+ /** Port of the application being proxied. */
41
+ readonly targetPort: number;
42
+ /** Host of the application being proxied. Defaults to loopback. */
43
+ readonly targetHost?: string;
44
+ /** Port the HTTP control channel listens on. Defaults to `listenPort + 1000`. */
45
+ readonly controlPort?: number;
46
+ }
47
+
48
+ /** The commands the control channel accepts, and the proxy's own vocabulary. */
49
+ export type FlakyNetworkCommand = 'break' | 'release' | 'status';
50
+
51
+ /** Drives a running proxy. Implemented both in-process and over HTTP. */
52
+ export interface FlakyNetworkControl {
53
+ /** Kill the browser side of every live connection; leave the server side up. */
54
+ break(): Promise<string>;
55
+ /** Kill the server side of every connection `break` orphaned. */
56
+ release(): Promise<string>;
57
+ /** How many connections are live and how many are orphaned. */
58
+ status(): Promise<string>;
59
+ }
60
+
61
+ export interface FlakyNetworkProxy extends FlakyNetworkControl {
62
+ /** Base URL a browser should be pointed at, i.e. Playwright's `baseURL`. */
63
+ readonly url: string;
64
+ /** Control channel, for workers that cannot reach this object directly. */
65
+ readonly controlUrl: string;
66
+ close(): Promise<void>;
67
+ }
68
+
69
+ interface ConnectionPair {
70
+ readonly id: number;
71
+ readonly browserSocket: Socket;
72
+ readonly targetSocket: Socket;
73
+ orphaned: boolean;
74
+ }
75
+
76
+ /**
77
+ * Starts the proxy in the calling process.
78
+ *
79
+ * Call it from a Playwright config, which runs in the main process before any
80
+ * worker forks. Workers are separate processes and cannot reach the returned
81
+ * object, which is what {@link controlUrl} and {@link flakyNetworkControlClient}
82
+ * are for.
83
+ */
84
+ export async function startFlakyNetworkProxy(options: FlakyNetworkProxyOptions): Promise<FlakyNetworkProxy> {
85
+ const targetHost = options.targetHost ?? '127.0.0.1';
86
+ const controlPort = options.controlPort ?? options.listenPort + 1000;
87
+ const pairs = new Set<ConnectionPair>();
88
+ let sequence = 0;
89
+ let transcript: string[] = [];
90
+
91
+ const log = (message: string): void => {
92
+ transcript.push(message);
93
+ console.log(`[flaky-network] ${message}`);
94
+ };
95
+
96
+ const proxyServer: TcpServer = createTcpServer(browserSocket => {
97
+ const id = ++sequence;
98
+ const targetSocket = connect(options.targetPort, targetHost);
99
+ const pair: ConnectionPair = { id, browserSocket, targetSocket, orphaned: false };
100
+ pairs.add(pair);
101
+
102
+ // Forwarded by hand rather than with `pipe()`, so killing one side does not
103
+ // tear the other down — which is the entire point of the proxy.
104
+ browserSocket.on('data', chunk => {
105
+ if (!targetSocket.destroyed) {
106
+ targetSocket.write(chunk);
107
+ }
108
+ });
109
+ targetSocket.on('data', chunk => {
110
+ if (!pair.orphaned && !browserSocket.destroyed) {
111
+ browserSocket.write(chunk);
112
+ }
113
+ });
114
+
115
+ const teardown = (origin: 'browser' | 'target'): void => {
116
+ if (pair.orphaned && origin === 'browser') {
117
+ // Expected: `break` killed this side deliberately. The server side
118
+ // stays up until `release`.
119
+ return;
120
+ }
121
+ pairs.delete(pair);
122
+ browserSocket.destroy();
123
+ targetSocket.destroy();
124
+ };
125
+
126
+ browserSocket.on('close', () => teardown('browser'));
127
+ targetSocket.on('close', () => teardown('target'));
128
+ // A half-killed pair reliably raises ECONNRESET on the surviving side; an
129
+ // unhandled 'error' would take the whole runner down with it.
130
+ browserSocket.on('error', () => undefined);
131
+ targetSocket.on('error', () => undefined);
132
+ });
133
+
134
+ const commands: Record<FlakyNetworkCommand, () => void> = {
135
+ break: () => {
136
+ const live = [...pairs].filter(pair => !pair.orphaned);
137
+ if (live.length === 0) {
138
+ log('nothing to break');
139
+ return;
140
+ }
141
+ for (const pair of live) {
142
+ pair.orphaned = true;
143
+ pair.browserSocket.destroy();
144
+ }
145
+ log(`${live.length} connection(s) broken; the browser should reconnect shortly`);
146
+ },
147
+ release: () => {
148
+ const orphans = [...pairs].filter(pair => pair.orphaned);
149
+ if (orphans.length === 0) {
150
+ log('no orphaned connections; run `break` first');
151
+ return;
152
+ }
153
+ for (const pair of orphans) {
154
+ pairs.delete(pair);
155
+ pair.targetSocket.destroy();
156
+ }
157
+ log(`${orphans.length} orphaned connection(s) destroyed; their late disconnect fires now`);
158
+ },
159
+ status: () => {
160
+ const live = [...pairs].filter(pair => !pair.orphaned).length;
161
+ const orphaned = pairs.size - live;
162
+ log(`${live} live, ${orphaned} orphaned`);
163
+ }
164
+ };
165
+
166
+ const run = (name: string): string => {
167
+ transcript = [];
168
+ const command = commands[name as FlakyNetworkCommand];
169
+ if (command) {
170
+ command();
171
+ } else {
172
+ log(`unknown command '${name}'; use: ${Object.keys(commands).join(' | ')}`);
173
+ }
174
+ return transcript.join('\n');
175
+ };
176
+
177
+ const controlServer: HttpServer = createHttpServer((request, response) => {
178
+ const output = run((request.url ?? '').replace(/^\//, '').split('?')[0]);
179
+ response.writeHead(200, { 'content-type': 'text/plain' });
180
+ response.end(`${output}\n`);
181
+ });
182
+
183
+ await Promise.all([listen(proxyServer, options.listenPort), listen(controlServer, controlPort)]);
184
+ // Neither server keeps the process alive, so a runner that starts one from a
185
+ // config does not need a teardown hook to exit — and a caller that wants a
186
+ // deterministic shutdown still has `close()`.
187
+ proxyServer.unref();
188
+ controlServer.unref();
189
+
190
+ return {
191
+ url: `http://localhost:${options.listenPort}`,
192
+ controlUrl: `http://localhost:${controlPort}`,
193
+ break: async () => run('break'),
194
+ release: async () => run('release'),
195
+ status: async () => run('status'),
196
+ close: async () => {
197
+ for (const pair of pairs) {
198
+ pair.browserSocket.destroy();
199
+ pair.targetSocket.destroy();
200
+ }
201
+ pairs.clear();
202
+ await Promise.all([close(proxyServer), close(controlServer)]);
203
+ }
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Drives a proxy running in another process, over its control channel.
209
+ *
210
+ * This is what a spec uses: Playwright workers are separate processes from the
211
+ * config that started the proxy.
212
+ */
213
+ export function flakyNetworkControlClient(controlUrl: string): FlakyNetworkControl {
214
+ const send = async (command: FlakyNetworkCommand): Promise<string> => {
215
+ const response = await fetch(`${controlUrl}/${command}`);
216
+ if (!response.ok) {
217
+ throw new Error(`flaky-network proxy rejected '${command}': ${response.status}`);
218
+ }
219
+ return (await response.text()).trim();
220
+ };
221
+ return {
222
+ break: () => send('break'),
223
+ release: () => send('release'),
224
+ status: () => send('status')
225
+ };
226
+ }
227
+
228
+ function listen(server: TcpServer | HttpServer, port: number): Promise<void> {
229
+ return new Promise((resolve, reject) => {
230
+ server.once('error', reject);
231
+ server.listen(port, () => {
232
+ server.off('error', reject);
233
+ resolve();
234
+ });
235
+ });
236
+ }
237
+
238
+ function close(server: TcpServer | HttpServer): Promise<void> {
239
+ return new Promise(resolve => server.close(() => resolve()));
240
+ }
@@ -11,6 +11,7 @@
11
11
  // `@hydranium/core/testing` barrel) so importing the framework's unit-test
12
12
  // helpers never pulls in `@playwright/test` — which is an OPTIONAL peer here.
13
13
  export * from './e2e-profiling.js';
14
+ export * from './flaky-network-proxy.js';
14
15
  export * from './browser-capture-bridge.js';
15
16
  export * from './server-log-capture.js';
16
17
  export * from './server-log-fixture.js';