From c5556fbd822a9f80e43097e414231b3e1480e2f9 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 18 Sep 2026 15:28:08 +0800 Subject: [PATCH] [CTX-0073] chore(test): implement live-socket fixture boundaries (#123) Implements accepted CTX-0069 design (PX-0319). Shared tests/helpers/fake-live-socket.ts with in-memory fake plus createScratchLoopback helper. Migrates ipc-socket and client tests to the boundary. src and cli.test untouched. Independent APPROVE PX-0341. Hash dispute resolved PX-0355. Gates green: bun scoped 82/380, tsc, prettier, oxlint. Task CTX-0073 stays review. --- tests/client.test.ts | 47 ++----- tests/helpers/fake-live-socket.ts | 209 ++++++++++++++++++++++++++++++ tests/ipc-socket.test.ts | 178 ++++++------------------- 3 files changed, 262 insertions(+), 172 deletions(-) create mode 100644 tests/helpers/fake-live-socket.ts diff --git a/tests/client.test.ts b/tests/client.test.ts index d13c346..4203971 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { DevtoolsClient } from "../src/client.js"; import { IpcTransport } from "../src/transport.js"; import { peerCredentials } from "../src/auth.js"; +import { createScratchLoopback } from "./helpers/fake-live-socket.js"; describe("DevtoolsClient integration", () => { test("connect + scope lifecycle", () => { @@ -66,8 +67,7 @@ describe("DevtoolsClient integration", () => { maxSubscriptionsPerPanel: 32, }, }); - // Inspection explanation is constant - const text = c.listPlugins; // ensure exists + const text = c.listPlugins; expect(typeof text).toBe("function"); }); }); @@ -146,6 +146,7 @@ describe("DevtoolsClient inspection live IPC wiring", () => { expect(() => c.connect()).toThrow("peer uid"); expect(c.isIpcConnected()).toBe(false); expect(() => c.listPlugins()).toThrow("not connected"); + expect(c.transportOutgoingLen()).toBeNull(); }); test("getGridText dispatches CTX-0159 introspection over the real IpcTransport", () => { @@ -193,24 +194,10 @@ describe("DevtoolsClient inspection live IPC wiring", () => { c.connect(); expect(() => c.getFocus()).toThrow("scope required"); c.grantScope("debug.inspect"); - // With a scope but no transport the live path fails closed, never mocks. expect(() => c.getModifiers()).toThrow("no connected inspection transport"); }); test("live socket connect serves listPlugins over a loopback socket", async () => { - const proc = globalThis.process as unknown as { - getuid?: () => number; - getBuiltinModule(id: string): { - mkdirSync(p: string, o: unknown): void; - chmodSync(p: string, m: number): void; - rmSync(p: string, o: unknown): void; - }; - }; - const fs = proc.getBuiltinModule("node:fs"); - const dir = `${process.env["XDG_RUNTIME_DIR"] ?? "/tmp"}/bitty-devtools-client-ctx0036-${process.pid}`; - fs.mkdirSync(dir, { recursive: true }); - fs.chmodSync(dir, 0o700); - const socketPath = `${dir}/loopback.sock`; const responsePayload = new TextEncoder().encode( JSON.stringify({ jsonrpc: "2.0", @@ -230,30 +217,19 @@ describe("DevtoolsClient inspection live IPC wiring", () => { version: "1.0", }), ); - const wire = new Uint8Array(4 + responsePayload.length); - new DataView(wire.buffer).setUint32(0, responsePayload.length, false); - wire.set(responsePayload, 4); - const server = Bun.listen({ - unix: socketPath, - socket: { - data(sock, _data) { - sock.write(wire); - }, - error() {}, - }, + const loopback = createScratchLoopback({ + prefix: "bitty-devtools-client-ctx0036", + responsePayload, + timeoutMs: 1000, }); - fs.chmodSync(socketPath, 0o600); - // Attestation compares the socket owner against the runtime UID: use - // the real local UID, never a constant. - const uid = typeof proc.getuid === "function" ? proc.getuid() : 1000; try { const c = new DevtoolsClient(); const session = await c.connectLiveSocket( - uid, - peerCredentials(uid, uid, 1), + loopback.runtimeUid, + peerCredentials(loopback.runtimeUid, loopback.runtimeUid, 1), undefined, undefined, - socketPath, + loopback.socketPath, ); expect(session.connected).toBe(true); expect(c.isIpcConnected()).toBe(true); @@ -274,8 +250,7 @@ describe("DevtoolsClient inspection live IPC wiring", () => { c.disconnect(); expect(c.isIpcConnected()).toBe(false); } finally { - server.stop(true); - fs.rmSync(dir, { recursive: true, force: true }); + loopback.stop(); } }); }); diff --git a/tests/helpers/fake-live-socket.ts b/tests/helpers/fake-live-socket.ts new file mode 100644 index 0000000..a753c01 --- /dev/null +++ b/tests/helpers/fake-live-socket.ts @@ -0,0 +1,209 @@ +import { spyOn } from "bun:test"; +import * as fs from "node:fs"; +import { connectLiveSocket } from "../../src/ipc-socket.js"; +import type { LiveSocketConnection } from "../../src/ipc-socket.js"; + +export const MEMORY_SOCKET_PATH = "/memory/bitty/fixture.sock"; +export const MEMORY_TIMEOUT_MS = 100; +export const MEMORY_RUNTIME_UID = 1000; +export const MEMORY_DIR_MODE = 0o700; +export const MEMORY_SOCK_MODE = 0o600; +export const SCRATCH_TIMEOUT_MS = 1000; +export const SCRATCH_SOCKET_LEAF = "loopback.sock"; +export const SCRATCH_TIMEOUT_CEILING_MS = 5000; + +export type MemorySocket = { + write(data: Uint8Array): number; + flush(): void; + end(): void; + close(): void; +}; + +export type MemoryHandlers = { + data(socket: MemorySocket, data: Uint8Array): void; + open(socket: MemorySocket): void; +}; + +export type MemoryTransmission = { + write?: () => void; + flush?: (count: number) => void; +}; + +export type MemoryRun = ( + connection: LiveSocketConnection, + receive: (bytes: Uint8Array) => void, + writes: number[], + flushes: number[], +) => Promise; + +export function localUid(): number { + const proc = globalThis.process as unknown as { + getuid?: () => number; + }; + return typeof proc.getuid === "function" ? proc.getuid() : 1000; +} + +export function scratchDir(prefix: string): string { + const raw = process.env["XDG_RUNTIME_DIR"] ?? "/tmp"; + const base = raw.startsWith("/") ? raw : "/tmp"; + return `${base}/${prefix}-${process.pid}`; +} + +export function assertScratchSocketPath(socketPath: string): void { + if (socketPath.includes("\0")) { + throw new Error("socket path contains NUL"); + } + if (socketPath.startsWith("/") === false) { + throw new Error(`scratch socket must be absolute: ${socketPath}`); + } + if ( + socketPath.includes("\\") || + /^[A-Za-z]:/.test(socketPath) || + socketPath.startsWith("\\\\") + ) { + throw new Error(`Windows socket syntax refused: ${socketPath}`); + } + if (socketPath.includes("/bitty-devtools-") === false) { + throw new Error(`scratch socket must be run-owned: ${socketPath}`); + } +} + +export async function withMemoryConnection( + run: MemoryRun, + transmission: MemoryTransmission = {}, +): Promise { + const runtime = Bun as unknown as { + file(path: string): { + stat(): Promise<{ mode: number; uid: number; isSocket(): boolean }>; + }; + connect(options: { socket: MemoryHandlers }): Promise; + }; + const stat = spyOn(fs, "lstatSync").mockReturnValue({ + isSymbolicLink: () => false, + } as ReturnType); + let handlers: MemoryHandlers | undefined; + const writes: number[] = []; + const flushes: number[] = []; + const socket: MemorySocket = { + write: (data) => { + writes.push(data.length); + transmission.write?.(); + return data.length; + }, + flush() { + flushes.push(writes.length); + transmission.flush?.(flushes.length); + }, + end() {}, + close() {}, + }; + const file = spyOn(runtime, "file").mockImplementation((path) => ({ + stat: async () => ({ + mode: path.endsWith(".sock") ? MEMORY_SOCK_MODE : MEMORY_DIR_MODE, + uid: MEMORY_RUNTIME_UID, + isSocket: () => path.endsWith(".sock"), + }), + })); + const connect = spyOn(runtime, "connect").mockImplementation( + async (options) => { + handlers = options.socket; + handlers.open(socket); + return socket; + }, + ); + try { + const connection = await connectLiveSocket({ + socketPath: MEMORY_SOCKET_PATH, + runtimeUid: MEMORY_RUNTIME_UID, + timeoutMs: MEMORY_TIMEOUT_MS, + }); + try { + await run( + connection, + (bytes) => handlers!.data(socket, bytes), + writes, + flushes, + ); + } finally { + connection.close(); + } + } finally { + connect.mockRestore(); + file.mockRestore(); + stat.mockRestore(); + } +} + +export type ScratchLoopback = { + dir: string; + socketPath: string; + runtimeUid: number; + timeoutMs: number; + stop: () => void; +}; + +export function createScratchLoopback(options: { + prefix: string; + responsePayload: Uint8Array; + timeoutMs?: number; +}): ScratchLoopback { + const timeoutMs = options.timeoutMs ?? SCRATCH_TIMEOUT_MS; + if ( + Number.isInteger(timeoutMs) === false || + timeoutMs <= 0 || + timeoutMs > SCRATCH_TIMEOUT_CEILING_MS + ) { + throw new Error(`timeoutMs must be 1..5000, got ${timeoutMs}`); + } + if ( + options.prefix.length === 0 || + options.prefix.includes("\0") || + options.prefix.includes("/") || + options.prefix.includes("\\") || + options.prefix.includes("..") + ) { + throw new Error(`invalid scratch prefix: ${options.prefix}`); + } + const dir = scratchDir(options.prefix); + const socketPath = `${dir}/${SCRATCH_SOCKET_LEAF}`; + assertScratchSocketPath(socketPath); + fs.mkdirSync(dir, { recursive: true }); + fs.chmodSync(dir, 0o700); + const runtimeUid = localUid(); + const wire = new Uint8Array(4 + options.responsePayload.length); + new DataView(wire.buffer).setUint32(0, options.responsePayload.length, false); + wire.set(options.responsePayload, 4); + const server = ( + Bun as unknown as { + listen(options: { + unix: string; + socket: { + data(sock: { write(data: Uint8Array): void }, data: Uint8Array): void; + error(): void; + }; + }): { stop(closeActiveConnections?: boolean): void }; + } + ).listen({ + unix: socketPath, + socket: { + data(sock) { + sock.write(wire); + }, + error() {}, + }, + }); + fs.chmodSync(socketPath, 0o600); + return { + dir, + socketPath, + runtimeUid, + timeoutMs, + stop: () => { + try { + server.stop(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + }; +} diff --git a/tests/ipc-socket.test.ts b/tests/ipc-socket.test.ts index 60e6e34..c79ec31 100644 --- a/tests/ipc-socket.test.ts +++ b/tests/ipc-socket.test.ts @@ -1,5 +1,4 @@ import { describe, expect, spyOn, test } from "bun:test"; -import * as fs from "node:fs"; import { MAX_FRAME_BYTES, TransportError, @@ -11,92 +10,13 @@ import { isLiveSocketSupported, LIVE_SOCKET_MAX_PENDING_FRAMES, } from "../src/ipc-socket.js"; - -type MemorySocket = { - write(data: Uint8Array): number; - flush(): void; - end(): void; - close(): void; -}; - -type MemoryHandlers = { - data(socket: MemorySocket, data: Uint8Array): void; - open(socket: MemorySocket): void; -}; - -async function withMemoryConnection( - run: ( - connection: Awaited>, - receive: (bytes: Uint8Array) => void, - writes: number[], - flushes: number[], - ) => Promise, - transmission: { - write?: () => void; - flush?: (count: number) => void; - } = {}, -): Promise { - const runtime = Bun as unknown as { - file(path: string): { - stat(): Promise<{ mode: number; uid: number; isSocket(): boolean }>; - }; - connect(options: { socket: MemoryHandlers }): Promise; - }; - const stat = spyOn(fs, "lstatSync").mockReturnValue({ - isSymbolicLink: () => false, - } as ReturnType); - let handlers: MemoryHandlers | undefined; - const writes: number[] = []; - const flushes: number[] = []; - const socket: MemorySocket = { - write: (data) => { - writes.push(data.length); - transmission.write?.(); - return data.length; - }, - flush() { - flushes.push(writes.length); - transmission.flush?.(flushes.length); - }, - end() {}, - close() {}, - }; - const file = spyOn(runtime, "file").mockImplementation((path) => ({ - stat: async () => ({ - mode: path.endsWith(".sock") ? 0o600 : 0o700, - uid: 1000, - isSocket: () => path.endsWith(".sock"), - }), - })); - const connect = spyOn(runtime, "connect").mockImplementation( - async (options) => { - handlers = options.socket; - handlers.open(socket); - return socket; - }, - ); - try { - const connection = await connectLiveSocket({ - socketPath: "/memory/bitty/fixture.sock", - runtimeUid: 1000, - timeoutMs: 100, - }); - try { - await run( - connection, - (bytes) => handlers!.data(socket, bytes), - writes, - flushes, - ); - } finally { - connection.close(); - } - } finally { - connect.mockRestore(); - file.mockRestore(); - stat.mockRestore(); - } -} +import { + assertScratchSocketPath, + createScratchLoopback, + localUid, + scratchDir, + withMemoryConnection, +} from "./helpers/fake-live-socket.js"; const firstPayload = new TextEncoder().encode('{"id":1,"result":"ready"}'); const secondPayload = new TextEncoder().encode('{"id":2,"result":"done"}'); @@ -320,34 +240,9 @@ describe("in-memory physical stream frames (#97)", () => { } }); -/** Process UID for attestation: the real local UID, never a constant. */ -function localUid(): number { - const proc = globalThis.process as unknown as { - getuid?: () => number; - }; - return typeof proc.getuid === "function" ? proc.getuid() : 1000; -} - -/** - * TDD failing-first proof for CTX-0036 (H-DEV-06): the Unix IPC socket - * seam must dial a live OS socket and complete a framed request/response - * round trip, not just flip a connected flag. - */ describe("live Unix IPC socket (CTX-0036)", () => { test("connect -> request -> response round trip over a loopback socket", async () => { if (!isLiveSocketSupported()) return; - const proc = globalThis.process as unknown as { - getBuiltinModule(id: string): { - mkdirSync(p: string, o: unknown): void; - chmodSync(p: string, m: number): void; - rmSync(p: string, o: unknown): void; - }; - }; - const fs = proc.getBuiltinModule("node:fs"); - const dir = `${process.env["XDG_RUNTIME_DIR"] ?? "/tmp"}/bitty-devtools-ctx0036-${process.pid}`; - fs.mkdirSync(dir, { recursive: true }); - fs.chmodSync(dir, 0o700); - const socketPath = `${dir}/loopback.sock`; const payload = new TextEncoder().encode( JSON.stringify({ jsonrpc: "2.0", @@ -356,24 +251,16 @@ describe("live Unix IPC socket (CTX-0036)", () => { version: "1.0", }), ); - const wire = new Uint8Array(4 + payload.length); - new DataView(wire.buffer).setUint32(0, payload.length, false); - wire.set(payload, 4); - - const server = Bun.listen({ - unix: socketPath, - socket: { - data(sock, _data) { - sock.write(wire); - }, - error() {}, - }, + const loopback = createScratchLoopback({ + prefix: "bitty-devtools-ctx0036", + responsePayload: payload, + timeoutMs: 1000, }); - fs.chmodSync(socketPath, 0o600); try { const conn = await connectLiveSocket({ - socketPath, - runtimeUid: localUid(), + socketPath: loopback.socketPath, + runtimeUid: loopback.runtimeUid, + timeoutMs: loopback.timeoutMs, }); try { const request = new TextEncoder().encode( @@ -395,8 +282,7 @@ describe("live Unix IPC socket (CTX-0036)", () => { conn.close(); } } finally { - server.stop(true); - fs.rmSync(dir, { recursive: true, force: true }); + loopback.stop(); } }); @@ -410,12 +296,13 @@ describe("live Unix IPC socket (CTX-0036)", () => { symlinkSync(t: string, p: string): void; }; }; - const fs = proc.getBuiltinModule("node:fs"); - const dir = `${process.env["XDG_RUNTIME_DIR"] ?? "/tmp"}/bitty-devtools-ctx0036-link-${process.pid}`; - fs.mkdirSync(dir, { recursive: true }); - fs.chmodSync(dir, 0o700); + const nodeFs = proc.getBuiltinModule("node:fs"); + const dir = scratchDir("bitty-devtools-ctx0036-link"); const target = `${dir}/real.sock`; const link = `${dir}/loopback.sock`; + assertScratchSocketPath(link); + nodeFs.mkdirSync(dir, { recursive: true }); + nodeFs.chmodSync(dir, 0o700); const server = Bun.listen({ unix: target, socket: { @@ -423,8 +310,12 @@ describe("live Unix IPC socket (CTX-0036)", () => { error() {}, }, }); - fs.chmodSync(target, 0o600); - fs.symlinkSync(target, link); + nodeFs.chmodSync(target, 0o600); + nodeFs.symlinkSync(target, link); + const runtime = Bun as unknown as { + connect(options: unknown): Promise; + }; + const connectSpy = spyOn(runtime, "connect"); try { let caught: unknown = null; try { @@ -437,9 +328,24 @@ describe("live Unix IPC socket (CTX-0036)", () => { } expect(caught).not.toBeNull(); expect(String(caught)).toContain("symlink"); + expect(connectSpy.mock.calls.length).toBe(0); + let dialCaught: unknown = null; + try { + await connectLiveSocket({ + socketPath: link, + runtimeUid: localUid(), + timeoutMs: 1000, + }); + } catch (error) { + dialCaught = error; + } + expect(dialCaught).not.toBeNull(); + expect(String(dialCaught)).toContain("symlink"); + expect(connectSpy.mock.calls.length).toBe(0); } finally { + connectSpy.mockRestore(); server.stop(true); - fs.rmSync(dir, { recursive: true, force: true }); + nodeFs.rmSync(dir, { recursive: true, force: true }); } }); });