Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 11 additions & 36 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -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();
}
});
});
209 changes: 209 additions & 0 deletions tests/helpers/fake-live-socket.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

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<void> {
const runtime = Bun as unknown as {
file(path: string): {
stat(): Promise<{ mode: number; uid: number; isSocket(): boolean }>;
};
connect(options: { socket: MemoryHandlers }): Promise<MemorySocket>;
};
const stat = spyOn(fs, "lstatSync").mockReturnValue({
isSymbolicLink: () => false,
} as ReturnType<typeof fs.lstatSync>);
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 });
}
},
};
}
Loading
Loading