test(web): add race-free loopback smoke
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@ export async function runWeb(argv: string[]): Promise<void> {
|
||||
}
|
||||
const hostAddress = values.host
|
||||
const port = Number(values.port)
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,39 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
||||
})
|
||||
if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
|
||||
|
||||
describe('dsh web keyless CLI smoke', () => {
|
||||
it('listens on 127.0.0.1 by default', async () => {
|
||||
requireDist()
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-'))
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-no-call',
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
try {
|
||||
const readyUrl = await waitForReadyLine(child)
|
||||
expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect((await fetch(readyUrl)).status).toBe(200)
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
if (child.exitCode === null) child.kill('SIGTERM')
|
||||
await closed
|
||||
rmSync(sessionsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
let child: ChildProcess
|
||||
let sessionsDir: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
|
||||
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { dirname } from 'node:path'
|
||||
import { serveStatic } from './static.ts'
|
||||
import type { HostWebPluginRegistry } from './web-plugins.ts'
|
||||
@@ -23,7 +24,7 @@ export type {
|
||||
export interface WebServerOptions {
|
||||
/** Address or hostname to listen on. */
|
||||
host: string
|
||||
/** Port to listen on. */
|
||||
/** Port to listen on; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/**
|
||||
* Absolute path of index.html inside the static root — the caller resolves
|
||||
@@ -42,7 +43,7 @@ export interface WebServerOptions {
|
||||
|
||||
/** Listening web server handle. */
|
||||
export interface RunningWebServer {
|
||||
/** The listening port (for the shell's URL line; equals options.port). */
|
||||
/** The listening port, including the OS-assigned value when options.port is zero. */
|
||||
port: number
|
||||
/**
|
||||
* Shutdown: close + closeAllConnections (SSE connections never end on their
|
||||
@@ -118,7 +119,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', rejectListen)
|
||||
server.on('error', onError)
|
||||
resolveListen({ port, close })
|
||||
resolveListen({ port: (server.address() as AddressInfo).port, close })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startWebServer, type RunningWebServer } from '../src/index.ts'
|
||||
|
||||
/** RunningWebServer.port echoes options.port, so tests must pick a concrete free port up front. */
|
||||
/** Reserve a loopback port for tests that need to address a second server. */
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = createNetServer()
|
||||
@@ -114,9 +114,8 @@ async function boot(onError: (err: Error) => void = () => undefined): Promise<st
|
||||
describe('startWebServer', () => {
|
||||
it('reports the listening port and closes idempotently', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(server.port).toBe(port)
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(server.port).toBeGreaterThan(0)
|
||||
const first = server.close()
|
||||
const second = server.close()
|
||||
expect(second).toBe(first)
|
||||
@@ -135,11 +134,13 @@ describe('startWebServer', () => {
|
||||
queueMicrotask(callback as () => void)
|
||||
return this
|
||||
})
|
||||
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
|
||||
try {
|
||||
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
|
||||
await inertServer.close()
|
||||
} finally {
|
||||
address.mockRestore()
|
||||
listen.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user