Skip to content

Commit 1084d2e

Browse files
committed
test(cli): os dev with the requested port held serves, dispatches and pings on the shifted port
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5
1 parent 0a1061a commit 1084d2e

1 file changed

Lines changed: 119 additions & 7 deletions

File tree

‎packages/cli/test/dev-connector-env-parity.integration.test.ts‎

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,28 @@
5252
* module is the only copy), has no `OS_PORT` at all, and the materialized
5353
* `baseUrl` must read N.
5454
*
55+
* ## #26270 — a busy port's shift is the port the config reads
56+
*
57+
* `os dev` shifts off a busy port, but the shift happened in the `serve`
58+
* child after the parent's compile and the child's config load had both read
59+
* the requested port. Measured on the showcase with 41310 held: `os dev -p
60+
* 41310` served on 41311, `/api/v1/meta/connector/showcase_status_api` named
61+
* 41310 and the self-ping flow answered `400 FLOW_FAILED`. The `os dev` parent
62+
* now runs the child's walk before it compiles and exports the port it lands
63+
* on (`portFlagEnv`).
64+
*
65+
* So the third boot here holds a port for its whole life (`holdPort`), asks
66+
* `os dev -p` for it, and reads the shifted port out of the child's own
67+
* banner. The served and the materialized `baseUrl` must both name that port,
68+
* and a flow pinging the app through the connector (`fxp_ping`, the fixture's
69+
* `showcase_declarative_connector_ping`) must succeed there.
70+
*
5571
* ## Spawn shape
5672
*
5773
* `bin/run.js` with `NODE_ENV` unset, the shipped entry resolving commands from
5874
* `dist/` (`requireBuiltCli`), in its own process group (`dev` supervises a
5975
* `serve` grandchild). Each boot asks for a NON-ZERO port drawn with
60-
* `reservePort()`, because that number is the subject: the config must read
76+
* `reservePort()` (the busy-port boot holds its own with `holdPort()`), because that number is the subject: the config must read
6177
* the port the flag names, before the boot, and a kernel-assigned `-p 0` read
6278
* back afterwards cannot be known to the config (the draw is ledgered in
6379
* `released-port-production-spawn.pin.test.ts`). A lost race on that draw is
@@ -73,9 +89,11 @@ import { tmpdir } from 'node:os';
7389
import { join, resolve } from 'node:path';
7490
import { fileURLToPath } from 'node:url';
7591
import {
92+
boundPortFromBanner,
7693
childEnv,
7794
E2E_AUTH_SECRET,
7895
E2E_SECRET_KEY,
96+
holdPort,
7997
portContentionError,
8098
portDriftError,
8199
probeThroughChild,
@@ -101,6 +119,8 @@ const selfUrlFor = (port: number | string) => `http://127.0.0.1:${port}`;
101119
const CONNECTOR = 'fxp_self';
102120
/** Where the fixture provider's factory records the `providerConfig` it was handed. */
103121
const MATERIALIZED_FILE = 'materialized-provider-config.json';
122+
/** The flow that pings the app through the connector, as the showcase's self-ping does. */
123+
const PING_FLOW = 'fxp_ping';
104124
/** The development dev-admin seed — the operator on this boot. */
105125
const EMAIL = 'admin@objectos.ai';
106126
const PASSWORD = 'admin123';
@@ -130,7 +150,9 @@ const connector = (baseUrl: string) => ({
130150
* `examples/app-showcase/src/system/self-url.ts` reads it; its one plugin
131151
* contributes the `fxp` provider in `init()` (before the automation service
132152
* materializes in `start()`), and the factory writes what it was handed to
133-
* the project directory, the `serve` child's cwd.
153+
* the project directory, the `serve` child's cwd. Its one action, `ping`,
154+
* dials `/api/v1/health` at that `baseUrl` and fails on anything but a 2xx,
155+
* so `PING_FLOW` succeeds only when the `baseUrl` is the app's own address.
134156
*/
135157
const CONFIG_SOURCE = `import { writeFileSync } from 'node:fs';
136158
import { join } from 'node:path';
@@ -150,10 +172,34 @@ export default defineStack({
150172
init: async (ctx: any) => {
151173
ctx.getService('automation').registerConnectorProvider('fxp', (c: any) => {
152174
writeFileSync(join(process.cwd(), '${MATERIALIZED_FILE}'), JSON.stringify(c.providerConfig));
153-
return { def: { name: c.name, label: c.label, type: c.type, actions: [] }, handlers: {} };
175+
return {
176+
def: { name: c.name, label: c.label, type: c.type, actions: [{ key: 'ping', label: 'Ping' }] },
177+
handlers: {
178+
ping: async () => {
179+
const r = await fetch(\`\${c.providerConfig.baseUrl}/api/v1/health\`, { signal: AbortSignal.timeout(5000) });
180+
if (!r.ok) throw new Error(\`health answered \${r.status}\`);
181+
return { status: r.status };
182+
},
183+
},
184+
};
154185
});
155186
},
156187
}],
188+
flows: [{
189+
name: '${PING_FLOW}',
190+
label: 'Ping',
191+
type: 'autolaunched',
192+
status: 'active',
193+
nodes: [
194+
{ id: 'start', type: 'start', label: 'Start' },
195+
{ id: 'ping', type: 'connector_action', label: 'Ping', connectorConfig: { connectorId: '${CONNECTOR}', actionId: 'ping', input: {} } },
196+
{ id: 'end', type: 'end', label: 'End' },
197+
],
198+
edges: [
199+
{ id: 'e1', source: 'start', target: 'ping' },
200+
{ id: 'e2', source: 'ping', target: 'end' },
201+
],
202+
}],
157203
});
158204
`;
159205

@@ -162,13 +208,16 @@ interface Reading {
162208
port?: number;
163209
served?: unknown;
164210
materialized?: unknown;
211+
/** The status and body `POST /api/v1/automation/PING_FLOW/trigger` answered. */
212+
ping?: { status: number; body: unknown };
165213
error?: Error;
166214
}
167215

168216
const groups: ChildProcess[] = [];
169217
let dir: string;
170218
let devReading: Reading = {};
171219
let serveReading: Reading = {};
220+
let shiftReading: Reading = {};
172221

173222
interface Live {
174223
child: ChildProcess;
@@ -178,9 +227,16 @@ interface Live {
178227

179228
/**
180229
* Boot `bin/run.js ARGS` asking for `port`, resolved once the banner tail shows
181-
* the child listening on exactly that port.
230+
* the child listening on exactly that port; or, with `shiftsOff` (the port is
231+
* held), on the port its banner names, which must be another one.
182232
*/
183-
function boot(what: string, args: string[], port: number, env: Record<string, string | undefined>): Promise<Live> {
233+
function boot(
234+
what: string,
235+
args: string[],
236+
port: number,
237+
env: Record<string, string | undefined>,
238+
shiftsOff = false,
239+
): Promise<Live> {
184240
return new Promise((resolveBoot, rejectBoot) => {
185241
const child = spawn(
186242
process.execPath,
@@ -224,8 +280,16 @@ function boot(what: string, args: string[], port: number, env: Record<string, st
224280
);
225281
const onData = (d: unknown) => {
226282
out += String(d);
283+
if (!READY.test(out)) return;
284+
if (shiftsOff) {
285+
const readback = boundPortFromBanner(out);
286+
settle(readback.state === 'bound' && readback.port !== port
287+
? readback.port
288+
: new Error(`${what} held ${port} yet its banner reads ${JSON.stringify(readback)}\n--- output ---\n${out.slice(-4000)}`));
289+
return;
290+
}
227291
// A boot on another port than the drawn one lost the draw's race; say so.
228-
if (READY.test(out)) settle(portDriftError(out, what, port) ?? port);
292+
settle(portDriftError(out, what, port) ?? port);
229293
};
230294
child.stdout?.on('data', onData);
231295
child.stderr?.on('data', onData);
@@ -346,8 +410,39 @@ beforeAll(async () => {
346410
devReading = { error: error instanceof Error ? error : new Error(String(error)) };
347411
} finally {
348412
if (live) await stopGroup(live.child);
413+
live = undefined;
414+
rmSync(materializedPath, { force: true });
415+
}
416+
417+
// `os dev -p N` with N held for the whole boot: the server shifts off it.
418+
const held = await holdPort();
419+
try {
420+
live = await boot(
421+
'os dev -p (held)',
422+
['dev', '--no-watch', '--no-restart', '-d', 'file:shift.db', '-p', String(held.port)],
423+
held.port,
424+
{},
425+
true,
426+
);
427+
const port = Number(new URL(live.base).port);
428+
const signIn = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD });
429+
const token = signIn.body?.token;
430+
if (signIn.status !== 200 || typeof token !== 'string') {
431+
throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${live.output().slice(-3000)}`);
432+
}
433+
const meta = await http(live, 'GET', `/api/v1/meta/connector/${CONNECTOR}`, token);
434+
if (meta.status !== 200) {
435+
throw new Error(`GET /api/v1/meta/connector/${CONNECTOR} answered ${meta.status}: ${JSON.stringify(meta.body)}`);
436+
}
437+
const ping = await http(live, 'POST', `/api/v1/automation/${PING_FLOW}/trigger`, token, {});
438+
shiftReading = { port, served: servedBaseUrl(meta.body), materialized: materialized(live), ping };
439+
} catch (error) {
440+
shiftReading = { error: error instanceof Error ? error : new Error(String(error)) };
441+
} finally {
442+
if (live) await stopGroup(live.child);
443+
await held.release();
349444
}
350-
}, 2 * BOOT_TIMEOUT_MS + 60_000);
445+
}, 3 * BOOT_TIMEOUT_MS + 60_000);
351446

352447
afterAll(async () => {
353448
for (const child of groups) await stopGroup(child);
@@ -377,3 +472,20 @@ describe('#26243 — `os serve --port N` with no OS_PORT', () => {
377472
expect(materialized).toBe(selfUrlFor(port));
378473
});
379474
});
475+
476+
describe('#26270 — `os dev -p N` with N held serves, dispatches and pings on the port it shifted to', () => {
477+
it('dispatch materializes the connector on the bound port, not the held one', () => {
478+
const { port, materialized } = recorded(shiftReading);
479+
expect(materialized).toBe(selfUrlFor(port));
480+
});
481+
482+
it('/api/v1/meta/connector/NAME serves that same baseUrl', () => {
483+
const { port, served } = recorded(shiftReading);
484+
expect(served).toBe(selfUrlFor(port));
485+
});
486+
487+
it('a flow pinging the app through the connector succeeds', () => {
488+
const { ping } = recorded(shiftReading);
489+
expect(ping.status, JSON.stringify(ping.body)).toBe(200);
490+
});
491+
});

0 commit comments

Comments
 (0)