Skip to content

Commit ce3df1c

Browse files
jaredcosulichclaude
andcommitted
test: probe the 0.0.0.0 bind directly instead of racing the banner
The smoke check read the startup log the moment the editor first answered, but the editor prints its "running at 0.0.0.0" line after it starts serving, so on CI the check lost the race and failed every run. It now connects to the editor through this machine's external interface, which a loopback-only bind refuses, and only falls back to waiting for the banner when there is no external interface to probe. Also bump actions/checkout and actions/setup-node to v7 (Node 20 runtime deprecation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PB7HvKBDSrFmLz8WBptA3j
1 parent 03f24c2 commit ce3df1c

2 files changed

Lines changed: 53 additions & 13 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,21 @@ jobs:
1616
workspace-config:
1717
runs-on: ubuntu-latest
1818
steps:
19-
- uses: actions/checkout@v4
20-
- uses: actions/setup-node@v4
19+
- uses: actions/checkout@v7
20+
- uses: actions/setup-node@v7
2121
with:
2222
node-version: 20
2323
- run: npm run check:replit
2424

2525
smoke:
2626
runs-on: ubuntu-latest
2727
steps:
28-
- uses: actions/checkout@v4
28+
- uses: actions/checkout@v7
2929
# The smoke test clones HEAD, so it needs real history, not a
3030
# detached-commit-only checkout.
3131
with:
3232
fetch-depth: 0
33-
- uses: actions/setup-node@v4
33+
- uses: actions/setup-node@v7
3434
with:
3535
node-version: 20
3636
- run: npm ci --no-audit --no-fund

‎scripts/smoke.mjs‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616

1717
import { spawn, spawnSync } from "node:child_process";
1818
import { request as httpRequest } from "node:http";
19+
import { connect } from "node:net";
1920
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
20-
import { tmpdir } from "node:os";
21+
import { networkInterfaces, tmpdir } from "node:os";
2122
import { join } from "node:path";
2223
import { checkReplitConfig, checkStarterPackage } from "./replit-config.mjs";
2324

@@ -106,6 +107,31 @@ async function requirePortFree(port) {
106107
});
107108
}
108109

110+
/// The first non-internal IPv4 address of this machine, or undefined when it
111+
/// has none (an offline laptop, a network-less container).
112+
function externalIPv4() {
113+
for (const addrs of Object.values(networkInterfaces())) {
114+
for (const a of addrs ?? []) {
115+
if (a.family === "IPv4" && !a.internal) return a.address;
116+
}
117+
}
118+
return undefined;
119+
}
120+
121+
/// Whether something accepts TCP connections on host:port.
122+
function canConnect(host, port) {
123+
return new Promise((resolve) => {
124+
const socket = connect({ host, port: Number(port), timeout: 3000 });
125+
const done = (ok) => {
126+
socket.destroy();
127+
resolve(ok);
128+
};
129+
socket.once("connect", () => done(true));
130+
socket.once("error", () => done(false));
131+
socket.once("timeout", () => done(false));
132+
});
133+
}
134+
109135
/// Fetch the preview, retrying while the app-port proxy is still coming up.
110136
/// The proxy binds asynchronously after the editor answers on its own port, so
111137
/// a single immediate request races it and sees a 502.
@@ -235,14 +261,28 @@ try {
235261

236262
const root = await waitForBoot();
237263
check(`editor answered on ${BASE}`, true);
238-
// Match the address, not the sentence around it: the legacy path prints
239-
// "running at http://0.0.0.0:<port>" and hosted mode prints it in an indented
240-
// "local:" row. Pinning either phrasing tests the banner, not the bind.
241-
check(
242-
"bound to 0.0.0.0, not just loopback",
243-
new RegExp(`0\\.0\\.0\\.0:${PORT}`).test(log),
244-
`no 0.0.0.0:${PORT} in the startup output`,
245-
);
264+
// Test the bind itself, not the banner: a loopback-only bind refuses a
265+
// connection addressed to this machine's external interface. The banner is
266+
// only a fallback for machines with no such interface, and even then it is
267+
// waited for -- the editor prints it after it starts answering, so reading
268+
// the log the moment the first request succeeds races it (CI lost that race).
269+
const external = externalIPv4();
270+
if (external) {
271+
check(
272+
"bound to 0.0.0.0, not just loopback",
273+
await canConnect(external, PORT),
274+
`connection to ${external}:${PORT} was refused`,
275+
);
276+
} else {
277+
const banner = new RegExp(`0\\.0\\.0\\.0:${PORT}`);
278+
const deadline = Date.now() + 10_000;
279+
while (!banner.test(log) && Date.now() < deadline) await sleep(250);
280+
check(
281+
"bound to 0.0.0.0, not just loopback",
282+
banner.test(log),
283+
`no external interface to probe, and no 0.0.0.0:${PORT} in the startup output`,
284+
);
285+
}
246286

247287
// 5. GET / issues the session cookie on the HTML document.
248288
console.log("\nSession cookie");

0 commit comments

Comments
 (0)