|
| 1 | +import net from 'node:net' |
| 2 | +import { GATEWAY_HOST, GATEWAY_PORT, LLAMA_SERVER_PORT, MEDIA_PORT } from '../../src/shared/ports' |
| 3 | + |
| 4 | +/** |
| 5 | + * Precondition guard for specs that assert against the FIXED engine ports. |
| 6 | + * |
| 7 | + * Those ports are single-owner. When a dev has the real app (or `npm run dev`) running, it |
| 8 | + * owns 7878/7879/8439, and specs probing them talk to the WRONG process: |
| 9 | + * |
| 10 | + * - smoke.spec.ts asserts llama-server is unreachable on a fresh profile, but a running |
| 11 | + * app's llama-server answers on 8439, so the assertion inverts |
| 12 | + * - smoke.spec.ts polls the gateway for its own fixture model, but 7878 belongs to the |
| 13 | + * other app, which never serves that fixture |
| 14 | + * - resilience-single-instance.spec.ts waits for the ports to close after teardown, and the |
| 15 | + * other app's ports never close |
| 16 | + * |
| 17 | + * All three produced false failures that read exactly like product regressions. The app also |
| 18 | + * auto-falls-back to a free port when a fixed one is held, so the e2e instance may not even |
| 19 | + * be on the port the spec is probing. |
| 20 | + * |
| 21 | + * Rather than assert against a port we do not own, these specs SKIP with the reason. CI runs |
| 22 | + * on a clean machine, so it still executes them for real — which is what lets the e2e job |
| 23 | + * gate instead of running advisory. |
| 24 | + */ |
| 25 | + |
| 26 | +const ENGINE_PORTS: { port: number; what: string }[] = [ |
| 27 | + { port: GATEWAY_PORT, what: 'gateway' }, |
| 28 | + { port: MEDIA_PORT, what: 'media server' }, |
| 29 | + { port: LLAMA_SERVER_PORT, what: 'llama-server' } |
| 30 | +] |
| 31 | + |
| 32 | +/** |
| 33 | + * True when something is LISTENING on the port. A raw TCP connect, not an HTTP probe: a |
| 34 | + * server answering 404 is still an owner, and an HTTP error would misreport it as free. |
| 35 | + */ |
| 36 | +export const portIsBusy = async (port: number, timeoutMs = 400): Promise<boolean> => |
| 37 | + new Promise((resolve) => { |
| 38 | + const socket = net.connect({ port, host: GATEWAY_HOST }) |
| 39 | + const finish = (busy: boolean): void => { |
| 40 | + socket.destroy() |
| 41 | + resolve(busy) |
| 42 | + } |
| 43 | + socket.setTimeout(timeoutMs) |
| 44 | + socket.once('connect', () => finish(true)) |
| 45 | + socket.once('timeout', () => finish(false)) |
| 46 | + socket.once('error', () => finish(false)) |
| 47 | + }) |
| 48 | + |
| 49 | +/** Which canonical engine ports are already owned, as human-readable labels. */ |
| 50 | +export const busyEnginePorts = async (): Promise<string[]> => { |
| 51 | + const checks = await Promise.all( |
| 52 | + ENGINE_PORTS.map(async ({ port, what }) => |
| 53 | + (await portIsBusy(port)) ? `${what} :${port}` : null |
| 54 | + ) |
| 55 | + ) |
| 56 | + return checks.filter((entry): entry is string => entry !== null) |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * Reason string to pass to test.skip() when the fixed ports are not ours, or null when they |
| 61 | + * are all free and the spec can run for real. Call BEFORE launching the Electron app. |
| 62 | + */ |
| 63 | +export const enginePortsUnavailableReason = async (): Promise<string | null> => { |
| 64 | + const busy = await busyEnginePorts() |
| 65 | + if (busy.length === 0) return null |
| 66 | + return `engine ports already owned by another process (${busy.join(', ')}) — quit Off Grid AI Desktop / npm run dev and re-run. These specs assert against the fixed ports and cannot share them.` |
| 67 | +} |
0 commit comments