diff --git a/src/bridges/user/web/test/orpc-router.integration.test.ts b/src/bridges/user/web/test/orpc-router.integration.test.ts index c83d5b87..8978a6d7 100644 --- a/src/bridges/user/web/test/orpc-router.integration.test.ts +++ b/src/bridges/user/web/test/orpc-router.integration.test.ts @@ -7,8 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as fs from "node:fs"; import net from "node:net"; -import { tmpdir } from "node:os"; -import * as path from "node:path"; import WsWebSocket from "ws"; import { createORPCClient, getEventMeta } from "@orpc/client"; import { RPCLink, type WebSocketLike } from "@orpc/client/websocket"; @@ -19,6 +17,7 @@ import { freeLocalPort, unreachableHubUrl, } from "../../../../test/hub-helpers.js"; +import { scratchDirectory } from "../../../../test/scratch-directory.helper.js"; type MeshClient = ContractRouterClient; @@ -70,7 +69,7 @@ async function setup(): Promise<{ client: MeshClient; port: number }> { let isolatedHome: string | undefined; beforeEach(() => { - isolatedHome = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-orpc-home-")); + isolatedHome = scratchDirectory("agent-comms-orpc-home-"); vi.stubEnv("HOME", isolatedHome); }); diff --git a/src/bridges/user/web/test/web-assets.unit.test.ts b/src/bridges/user/web/test/web-assets.unit.test.ts index 14a9fcfd..ad024cea 100644 --- a/src/bridges/user/web/test/web-assets.unit.test.ts +++ b/src/bridges/user/web/test/web-assets.unit.test.ts @@ -4,13 +4,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import { createFrontendAssetLoader, FrontendNotBuiltError, loadFrontendAssets, } from "../web-assets.js"; +import { scratchDirectory } from "../../../../test/scratch-directory.helper.js"; const ICON_FILENAMES = [ "icon-96x96.svg", @@ -21,7 +21,7 @@ const ICON_FILENAMES = [ let assetDir: string; beforeEach(() => { - assetDir = fs.mkdtempSync(path.join(os.tmpdir(), "web-assets-")); + assetDir = scratchDirectory("web-assets-"); }); afterEach(() => { diff --git a/src/bridges/user/web/test/web-console-route.integration.test.ts b/src/bridges/user/web/test/web-console-route.integration.test.ts index 90b51029..c64fab75 100644 --- a/src/bridges/user/web/test/web-console-route.integration.test.ts +++ b/src/bridges/user/web/test/web-console-route.integration.test.ts @@ -4,7 +4,6 @@ import { describe, it, expect, afterEach } from "vitest"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import http from "node:http"; import net from "node:net"; @@ -14,6 +13,7 @@ import { freeLocalPort, unreachableHubUrl, } from "../../../../test/hub-helpers.js"; +import { scratchDirectory } from "../../../../test/scratch-directory.helper.js"; const ENV_VAR = "AGENT_COMMS_WEB_CONSOLE_DIST"; const HTTP_OK = 200; @@ -105,7 +105,7 @@ describe("web-console route (opt-in)", () => { it("serves web-console's index.html and assets when the dist dir is configured", async () => { originalEnv = process.env[ENV_VAR]; - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web-console-dist-")); + tmpDir = scratchDirectory("web-console-dist-"); fs.writeFileSync( path.join(tmpDir, "index.html"), "web-console", diff --git a/src/bridges/user/web/test/web-console-static.unit.test.ts b/src/bridges/user/web/test/web-console-static.unit.test.ts index b987aaba..491b0498 100644 --- a/src/bridges/user/web/test/web-console-static.unit.test.ts +++ b/src/bridges/user/web/test/web-console-static.unit.test.ts @@ -4,7 +4,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import type http from "node:http"; import { @@ -12,6 +11,7 @@ import { serveWebConsole, WEB_CONSOLE_MOUNT, } from "../web-console-static.js"; +import { scratchDirectory } from "../../../../test/scratch-directory.helper.js"; const ENV_VAR = "AGENT_COMMS_WEB_CONSOLE_DIST"; const HTTP_OK = 200; @@ -37,7 +37,7 @@ afterEach(() => { }); function makeDistDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "web-console-dist-")); + const dir = scratchDirectory("web-console-dist-"); fs.writeFileSync( path.join(dir, "index.html"), "web-console", @@ -62,7 +62,7 @@ describe("resolveWebConsoleDist", () => { }); it("returns undefined when the configured directory has no index.html", () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web-console-empty-")); + tmpDir = scratchDirectory("web-console-empty-"); process.env[ENV_VAR] = tmpDir; expect(resolveWebConsoleDist()).toBeUndefined(); }); diff --git a/src/test/account-join.integration.test.ts b/src/test/account-join.integration.test.ts index 26e539bb..d1c5bd69 100644 --- a/src/test/account-join.integration.test.ts +++ b/src/test/account-join.integration.test.ts @@ -3,8 +3,6 @@ */ import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import type { Clock } from "wire-mesh-core/ports/clock"; import { DEFAULT_CONNECTION_CODE_TTL_MS } from "../core/connection-code.js"; @@ -14,6 +12,7 @@ import type { Visibility } from "../core/types.js"; import { runAccountCommand, type AccountCliIo } from "../account-cli.js"; import { freeLocalPort, realHubOverWs, TeardownStack } from "./hub-helpers.js"; import { waitFor, wireTestTransport } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** A device-id is a 64-character lowercase hex SHA-256 digest. */ const DEVICE_ID_HEX_LENGTH = 64; @@ -33,7 +32,7 @@ afterEach(async () => { }); function userDir(): string { - const dir = fs.mkdtempSync(path.join(tmpdir(), "account-join-user-")); + const dir = scratchDirectory("account-join-user-"); dirs.push(dir); return dir; } diff --git a/src/test/account-ledger-helpers.ts b/src/test/account-ledger-helpers.ts index 170ef5b8..ebd8aaee 100644 --- a/src/test/account-ledger-helpers.ts +++ b/src/test/account-ledger-helpers.ts @@ -4,7 +4,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { tmpdir } from "node:os"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; @@ -19,6 +18,7 @@ import type { AccountLedger } from "../core/account-ledger.js"; import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import type { TeardownStack } from "./hub-helpers.js"; import { wireTestTransportWithHub } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Short enough that the ledger's announcement round, which rides the re-advertise cadence, fires many times within a test's wait budget. */ export const FAST_GOSSIP_INTERVAL_MS = 50; @@ -27,7 +27,7 @@ const dirs: string[] = []; /** A fresh throwaway directory for one machine's user-identity.json, removed by removeUserDirs. */ export function userDir(): string { - const dir = fs.mkdtempSync(path.join(tmpdir(), "ledger-replication-user-")); + const dir = scratchDirectory("ledger-replication-user-"); dirs.push(dir); return dir; } diff --git a/src/test/account-ledger-lock.integration.test.ts b/src/test/account-ledger-lock.integration.test.ts index cdd715c1..d87c04ef 100644 --- a/src/test/account-ledger-lock.integration.test.ts +++ b/src/test/account-ledger-lock.integration.test.ts @@ -3,7 +3,6 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; @@ -11,6 +10,7 @@ import { afterEach, expect, test } from "vitest"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; import { openAccountLedger } from "../core/account-ledger-store.js"; import { loadOrCreateUserIdentity } from "../core/user-identity.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WORKER_SCRIPT = path.join(__dirname, "account-ledger-lock-worker.ts"); @@ -64,7 +64,7 @@ test( "concurrent processes appending to one machine's ledger lose no grant", { timeout: START_DELAY_MS + TIMEOUT_HEADROOM_MS }, async () => { - const dir = fs.mkdtempSync(path.join(tmpdir(), "account-ledger-lock-")); + const dir = scratchDirectory("account-ledger-lock-"); dirs.push(dir); const userIdentityOptions = { dir }; const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); diff --git a/src/test/account-ledger.unit.test.ts b/src/test/account-ledger.unit.test.ts index f8c2b535..749bc89a 100644 --- a/src/test/account-ledger.unit.test.ts +++ b/src/test/account-ledger.unit.test.ts @@ -3,7 +3,6 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; @@ -38,6 +37,7 @@ import { readLegacyIssuedGrants, } from "../core/user-identity.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; type LedgerFrame = DataHaveFrame | DataRequestFrame | DataEntriesFrame; @@ -249,7 +249,7 @@ describe("account ledger replication", () => { it("never misses a replicated grant while another bridge on the machine is storing that log", async () => { const account = generateIdentity(); const origin = machineLedger(account); - const dir = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-ledger-fs-")); + const dir = scratchDirectory("agent-comms-ledger-fs-"); const tornWrite = pausingHeadWrites(createNodeFsStorage({ dir })); const machineLock = `${dir}.lock`; const replicator = new AccountLedger({ @@ -319,7 +319,7 @@ describe("account ledger replication", () => { describe("openAccountLedger", () => { function userDir(): string { - return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-account-ledger-")); + return scratchDirectory("agent-comms-account-ledger-"); } function writeLegacyGrants(dir: string): void { diff --git a/src/test/account-transfer.unit.test.ts b/src/test/account-transfer.unit.test.ts index 0b8fd9cd..68733031 100644 --- a/src/test/account-transfer.unit.test.ts +++ b/src/test/account-transfer.unit.test.ts @@ -3,7 +3,6 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -32,11 +31,12 @@ import { runAccountCommand, type AccountCliIo, } from "../account-cli.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const PASSPHRASE = "correct horse battery staple"; function tempDir(): string { - return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-account-transfer-")); + return scratchDirectory("agent-comms-account-transfer-"); } function principalIn(dir: string): string { diff --git a/src/test/bridge-mesh-data-directory.integration.test.ts b/src/test/bridge-mesh-data-directory.integration.test.ts index 94e9f856..311faf0a 100644 --- a/src/test/bridge-mesh-data-directory.integration.test.ts +++ b/src/test/bridge-mesh-data-directory.integration.test.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -36,13 +35,14 @@ import { unreachableHubUrl, } from "./hub-helpers.js"; import { waitFor } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; let root: string; let defaultDir: string; const cleanups = new TeardownStack(); beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), "agent-comms-data-dir-")); + root = scratchDirectory("agent-comms-data-dir-"); defaultDir = path.join(root, "environment-default"); vi.stubEnv(DATA_DIR_ENV_VAR, defaultDir); }); diff --git a/src/test/bridge-mesh-version-drift.integration.test.ts b/src/test/bridge-mesh-version-drift.integration.test.ts index 71546dcb..31be4c20 100644 --- a/src/test/bridge-mesh-version-drift.integration.test.ts +++ b/src/test/bridge-mesh-version-drift.integration.test.ts @@ -4,9 +4,6 @@ * fetchLatestVersion is injected in every test here specifically so this file never makes a real network call to the npm registry -- see bridge-mesh.ts's own header on why that parameter exists. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test, expect } from "vitest"; import { createBridgeMesh } from "../core/bridge-mesh.js"; import { freeLocalPort } from "./hub-helpers.js"; @@ -14,11 +11,10 @@ import { getOwnPackageVersion } from "../core/package-version.js"; import { buildAction } from "../core/bridge.js"; import type { IdentitySlot } from "../core/identity-store.js"; import type { CommsTool } from "../core/tool.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; function tempSlot(harness: string): IdentitySlot { - const dir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-bridge-mesh-version-drift-test-"), - ); + const dir = scratchDirectory("agent-comms-bridge-mesh-version-drift-test-"); return { harness, cwd: "/tmp/project", dir }; } diff --git a/src/test/bridge-mesh.integration.test.ts b/src/test/bridge-mesh.integration.test.ts index 3f3f5999..37e87a2c 100644 --- a/src/test/bridge-mesh.integration.test.ts +++ b/src/test/bridge-mesh.integration.test.ts @@ -2,9 +2,6 @@ * Unit tests for createBridgeMesh -- the shared factory every bridge builds its own MeshStore/WireMeshTransport/CommsTool from, replacing the identical four-line block each of the six bridges used to repeat against TlsTransport directly. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test, expect } from "vitest"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { @@ -20,14 +17,13 @@ import { import { waitFor } from "./test-transport.js"; import { freeLocalPort } from "./hub-helpers.js"; import { realHubOverWs, waitForCondition } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** A device-id is a 64-character lowercase hex SHA-256 digest. */ const DEVICE_ID_HEX_LENGTH = 64; function tempSlot(harness: string): IdentitySlot { - const dir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-bridge-mesh-test-"), - ); + const dir = scratchDirectory("agent-comms-bridge-mesh-test-"); return { harness, cwd: "/tmp/project", dir }; } diff --git a/src/test/connection-code-ledger-persistence.unit.test.ts b/src/test/connection-code-ledger-persistence.unit.test.ts index f71957ad..cbfc5766 100644 --- a/src/test/connection-code-ledger-persistence.unit.test.ts +++ b/src/test/connection-code-ledger-persistence.unit.test.ts @@ -3,7 +3,6 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { test, expect } from "vitest"; import { @@ -11,11 +10,10 @@ import { saveConnectionCodeLedger, type IdentitySlot, } from "../core/identity-store.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; function tempSlot(harness: string): IdentitySlot { - const dir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-connection-code-ledger-test-"), - ); + const dir = scratchDirectory("agent-comms-connection-code-ledger-test-"); return { harness, cwd: "/tmp/project", dir }; } diff --git a/src/test/connection-code.unit.test.ts b/src/test/connection-code.unit.test.ts index 9f1f62ea..d9796bb1 100644 --- a/src/test/connection-code.unit.test.ts +++ b/src/test/connection-code.unit.test.ts @@ -2,9 +2,6 @@ * Unit tests for ConnectionCodeLedger (agent-comms#188): the generate/redeem pair backing gateway_generate_connection_code/gateway_redeem_connection_code. Covers the always-checked nonce/expiry path standalone from the optional PGP-signature path, mirroring gateway-trust.test.ts's own split between plain-trust and slot-persistence describe blocks for the sibling allowlist class. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import * as openpgp from "openpgp"; import { describe, expect, it, beforeAll } from "vitest"; import { @@ -13,11 +10,10 @@ import { } from "../core/connection-code.js"; import type { IdentitySlot } from "../core/identity-store.js"; import type { ConnectionCode } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; function tempSlot(harness: string): IdentitySlot { - const dir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-connection-code-test-"), - ); + const dir = scratchDirectory("agent-comms-connection-code-test-"); return { harness, cwd: "/tmp/project", dir }; } diff --git a/src/test/device-membership.unit.test.ts b/src/test/device-membership.unit.test.ts index 17316c3e..b78b3286 100644 --- a/src/test/device-membership.unit.test.ts +++ b/src/test/device-membership.unit.test.ts @@ -1,9 +1,6 @@ /** * Unit tests for core/device-membership: admitDevice (mint, recorded in the account's grant ledger) and removeDevice (revoke every outstanding grant, recorded in the same ledger), the mint/revoke half of agent-comms#161. verifyDeviceMembership itself is covered separately in device-membership-verification.test.ts. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { describe, it, expect } from "vitest"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -20,13 +17,12 @@ import { groupPath, verifyDeviceMembership, } from "../core/device-membership-verification.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; function tempDir(): string { - return fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-device-membership-test-"), - ); + return scratchDirectory("agent-comms-device-membership-test-"); } /** A real, persisted user-principal identity and its account ledger, opened the way a real bridge process opens them. */ diff --git a/src/test/dm-grant-tool.integration.test.ts b/src/test/dm-grant-tool.integration.test.ts index 1d494474..04e5e207 100644 --- a/src/test/dm-grant-tool.integration.test.ts +++ b/src/test/dm-grant-tool.integration.test.ts @@ -3,8 +3,6 @@ */ import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { test, expect } from "vitest"; import { MeshStore } from "../core/mesh-store.js"; import { CommsTool } from "../core/tool.js"; @@ -12,6 +10,7 @@ import { dmRoomPath } from "../core/room-path.js"; import type { CommsContext } from "../core/tool.js"; import { waitFor, wireTestTransport } from "./test-transport.js"; import { freeLocalPort } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; interface Party { store: MeshStore; @@ -230,7 +229,7 @@ test("whoami reports the user principal, so a person can hand it to someone who test("admitting a person's principal lets every one of their devices DM with no decision, from the same grant text", async () => { const port = await freeLocalPort(); const receiver = await party(port, "receiver"); - const dir = fs.mkdtempSync(path.join(tmpdir(), "dm-grant-user-")); + const dir = scratchDirectory("dm-grant-user-"); const first = await partyOfUser(port, "first-device", dir); const second = await partyOfUser(port, "second-device", dir); diff --git a/src/test/dm-send-capability-admission.integration.test.ts b/src/test/dm-send-capability-admission.integration.test.ts index f6b3da89..2b1341e5 100644 --- a/src/test/dm-send-capability-admission.integration.test.ts +++ b/src/test/dm-send-capability-admission.integration.test.ts @@ -2,9 +2,6 @@ * Integration test for receiver-side DM gating with a user-issued capability (agent-comms#162): a receiver's own user principal can admit an agent into its DM-communication scope by minting a dm:send grant, which the agent then presents alongside its DM join request to auto-admit without needing a fresh human decision each time -- the durable admission list this issue adds, distinct from (and additive to) dm-admission.integration.test.ts's own two-round human-consent flow, which remains the fallback when no dm:send grant is presented at all. */ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { test, expect } from "vitest"; import { deviceIdFromHex, @@ -20,6 +17,7 @@ import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import { DM_SEND_CAPABILITY } from "../core/dm-token-verification.js"; import { waitFor, wireTestTransport } from "./test-transport.js"; import { freeLocalPort } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** A device-id, hex-encoded, is always exactly this many characters (32 raw bytes). */ const DEVICE_ID_HEX_LENGTH = 64; @@ -128,9 +126,7 @@ test("revoking a bearer that was never admitted is a harmless no-op", async () = /** B's own user-principal identity, loaded from a directory this test controls directly (rather than the throwaway one wireTestTransport would otherwise generate internally and never disclose), so a test can compute the exact "user" scope path (B's own principal device-id, hex) a dm:send grant B mints is rooted at -- agent-comms#187's own principal-keyed admission tests need this to construct a further delegation whose scope actually narrows the grant it chains from. */ function tempUserIdentityDir(): { dir: string } { return { - dir: fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-b-user-identity-test-"), - ), + dir: scratchDirectory("agent-comms-b-user-identity-test-"), }; } diff --git a/src/test/dm-send-delegation.unit.test.ts b/src/test/dm-send-delegation.unit.test.ts index c3e0bdf1..15a72ce8 100644 --- a/src/test/dm-send-delegation.unit.test.ts +++ b/src/test/dm-send-delegation.unit.test.ts @@ -1,9 +1,6 @@ /** * Unit tests for core/dm-send-delegation: delegateDmSendToDevice, the primitive that lets a user principal already admitted into a remote user's DM-communication scope (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0) sub-delegate that admission to one of its own devices (agent-comms#187) -- the dm:send counterpart to device-membership.test.ts's own admitDevice/removeDevice coverage. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { describe, it, expect } from "vitest"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; @@ -21,13 +18,14 @@ import { verifyDmSendToken, } from "../core/dm-token-verification.js"; import { delegateDmSendToDevice } from "../core/dm-send-delegation.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; const NON_DELEGABLE = 0; const ONE_HOP_DELEGABLE = 1; function tempDir(): string { - return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-dm-delegation-test-")); + return scratchDirectory("agent-comms-dm-delegation-test-"); } /** A real, persisted user-principal identity and its account ledger, which delegateDmSendToDevice records the delegation in, opened the way device-membership.test.ts's own makeUser() opens them. */ diff --git a/src/test/dm-send-principal-delegation.integration.test.ts b/src/test/dm-send-principal-delegation.integration.test.ts index ed60048e..d1ea2cd5 100644 --- a/src/test/dm-send-principal-delegation.integration.test.ts +++ b/src/test/dm-send-principal-delegation.integration.test.ts @@ -1,9 +1,6 @@ /** * End-to-end integration test for agent-comms#187: a user principal admitted into another user's DM-communication scope with room delegation depth (admitAgentForDm) mints, via delegateDmSendToDevice, a further dm:send token naming one of its own devices as bearer -- and that device's own requestDmAccess, presenting only the delegated token, auto-admits exactly the way a directly-admitted bare device already does in dm-send-capability-admission.integration.test.ts. Proves the full "trust this whole person, not just one of their devices" flow the issue names, end to end over a real wire connection. */ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { test, expect } from "vitest"; import { deviceIdFromHex, @@ -18,6 +15,7 @@ import { randomId } from "../core/random-id.js"; import { delegateDmSendToDevice } from "../core/dm-send-delegation.js"; import { waitFor, wireTestTransport } from "./test-transport.js"; import { freeLocalPort } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Delegated token expiry -- comfortably longer than a single test run, matching this file's sibling integration tests' own convention. */ const DELEGATED_TOKEN_TTL_MS = 60_000; @@ -26,9 +24,7 @@ const ONE_HOP_DELEGABLE = 1; function tempUserIdentityDir(): { dir: string } { return { - dir: fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-principal-delegation-test-"), - ), + dir: scratchDirectory("agent-comms-principal-delegation-test-"), }; } diff --git a/src/test/filestore.unit.test.ts b/src/test/filestore.unit.test.ts index e67ab6f4..811f8a64 100644 --- a/src/test/filestore.unit.test.ts +++ b/src/test/filestore.unit.test.ts @@ -3,13 +3,13 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { test, expect } from "vitest"; import { FileStore } from "../core/store.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; function tempStore(): FileStore { - const root = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-filestore-")); + const root = scratchDirectory("agent-comms-filestore-"); return new FileStore(root); } diff --git a/src/test/front-real-bridge-identity-overlap.integration.test.ts b/src/test/front-real-bridge-identity-overlap.integration.test.ts index 21a9c4c9..65029dab 100644 --- a/src/test/front-real-bridge-identity-overlap.integration.test.ts +++ b/src/test/front-real-bridge-identity-overlap.integration.test.ts @@ -5,9 +5,6 @@ */ import { afterEach, describe, expect, it } from "vitest"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { MeshStore } from "../core/mesh-store.js"; import { loadIdentityForFront, @@ -20,6 +17,7 @@ import { waitFor as waitForCondition, } from "./test-transport.js"; import { nanoid } from "../core/nanoid.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const cleanups = new TeardownStack(); @@ -52,7 +50,7 @@ describe("front and real bridge sharing one identity slot (agent-comms#299)", () const slot: IdentitySlot = { harness: "claude-code", cwd: "/test/fronted-session", - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-identity-")), + dir: scratchDirectory("agent-comms-test-identity-"), }; // The front: loadIdentityForFront never takes the slot lock, matching front.ts's own real behaviour. diff --git a/src/test/gateway-trust-recheck.unit.test.ts b/src/test/gateway-trust-recheck.unit.test.ts index 9d0a8890..a04eed17 100644 --- a/src/test/gateway-trust-recheck.unit.test.ts +++ b/src/test/gateway-trust-recheck.unit.test.ts @@ -1,9 +1,6 @@ /** * How often GatewayTrust looks at the shared trust file. A node answers trust questions for every directory entry it merges, so the look has to be rare by construction; these tests count the looks (never time them) through an injected clock and pin both halves of the bound: reads inside the interval cost none, and a change made by another process is seen once the interval has passed. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GatewayTrust, @@ -12,6 +9,7 @@ import { import type * as identityStore from "../core/identity-store.js"; import type { IdentitySlot } from "../core/identity-store.js"; import { manualClock } from "./manual-clock.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const stamps = vi.hoisted(() => ({ calls: 0 })); @@ -34,7 +32,7 @@ vi.mock("../core/identity-store.js", async (importOriginal) => { const QUESTIONS = 500; function tempSlot(): IdentitySlot { - const dir = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-trust-recheck-")); + const dir = scratchDirectory("agent-comms-trust-recheck-"); return { harness: "pi", cwd: "/tmp/project", dir }; } diff --git a/src/test/gateway-trust.unit.test.ts b/src/test/gateway-trust.unit.test.ts index 5534ec7c..821d7171 100644 --- a/src/test/gateway-trust.unit.test.ts +++ b/src/test/gateway-trust.unit.test.ts @@ -1,9 +1,6 @@ /** * Direct unit tests for GatewayTrust -- the cross-machine trust boundary's own allowlist (agent-comms#156), tested standalone against no real transport or hub socket, Also covers persistence (agent-comms#186) and its sharing across every store in an identity directory (agent-comms#293): loading a previously trusted set on construction, writing it back on every change, and picking up a change another instance made. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { GatewayTrust, @@ -11,11 +8,10 @@ import { } from "../core/gateway-trust.js"; import type { IdentitySlot } from "../core/identity-store.js"; import { manualClock } from "./manual-clock.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; function tempSlot(harness: string): IdentitySlot { - const dir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-gateway-trust-test-"), - ); + const dir = scratchDirectory("agent-comms-gateway-trust-test-"); return { harness, cwd: "/tmp/project", dir }; } diff --git a/src/test/identity-restart.integration.test.ts b/src/test/identity-restart.integration.test.ts index a623d417..548e4f87 100644 --- a/src/test/identity-restart.integration.test.ts +++ b/src/test/identity-restart.integration.test.ts @@ -4,9 +4,6 @@ * Before persistent identities, every restart generated a fresh keypair, so the device-id-derived agent ID changed and peers kept targeting the old ID: their messages queued for an agent whose local handler (gated on agentId === peerId) never fired again. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test, expect } from "vitest"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -27,6 +24,7 @@ import { openAccountLedger } from "../core/account-ledger-store.js"; import type { DeliveryEvent } from "../core/types.js"; import { ownerNamedRoomPath } from "../core/room-path.js"; import { freeLocalPort } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Attempts {@link waitFor} polls before giving up. */ const MAX_WAIT_ATTEMPTS = 20; @@ -58,7 +56,7 @@ async function makePeer( }), ); const userIdentityOptions = { - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-user-identity-")), + dir: scratchDirectory("agent-comms-test-user-identity-"), }; const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); store.setIdentity({ @@ -97,12 +95,12 @@ async function waitFor( async function main(): Promise { const port = await freeLocalPort(); - const dir = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-restart-test-")); + const dir = scratchDirectory("agent-comms-restart-test-"); const slot: IdentitySlot = { harness: "pi", cwd: "/tmp/project", dir }; const slotB: IdentitySlot = { harness: "claude-code", cwd: "/tmp/b", - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-restart-test-b-")), + dir: scratchDirectory("agent-comms-restart-test-b-"), }; // Peer B is a normal ephemeral bridge that stays up throughout. diff --git a/src/test/identity-store-race.integration.test.ts b/src/test/identity-store-race.integration.test.ts index 8ef0dd97..67e4d86d 100644 --- a/src/test/identity-store-race.integration.test.ts +++ b/src/test/identity-store-race.integration.test.ts @@ -2,12 +2,11 @@ * agent-comms#222 -- two or more real processes sharing one identity slot (harness, cwd) racing loadOrCreateIdentity+saveRoomToken concurrently against a slot that has never been touched before must never throw or corrupt the persisted identity, mirroring several independent Playwright e2e workers each starting a ChatController against the same cwd-derived slot. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { test, expect } from "vitest"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WORKER_SCRIPT = path.join(__dirname, "identity-store-race-worker.ts"); @@ -49,7 +48,7 @@ async function runWorker( } async function raceRound(): Promise { - const dir = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-identity-race-")); + const dir = scratchDirectory("agent-comms-identity-race-"); const startAt = Date.now() + START_DELAY_MS; const workers = Array.from({ length: WORKER_COUNT }, async () => runWorker(dir, "user", startAt), diff --git a/src/test/identity-store-wait.unit.test.ts b/src/test/identity-store-wait.unit.test.ts index cdeb5d10..fc73951f 100644 --- a/src/test/identity-store-wait.unit.test.ts +++ b/src/test/identity-store-wait.unit.test.ts @@ -6,7 +6,6 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import type * as FsModule from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; @@ -15,6 +14,7 @@ import { loadRoomTokens, saveRoomToken, } from "../core/identity-store.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const hooks = vi.hoisted(() => ({ onRead: undefined as ((filePath: string) => void) | undefined, @@ -61,7 +61,7 @@ describe("saveRoomToken racing a concurrent identity creation", () => { }); function scratchDir(): string { - const dir = fs.mkdtempSync(path.join(tmpdir(), "identity-wait-")); + const dir = scratchDirectory("identity-wait-"); dirs.push(dir); return dir; } diff --git a/src/test/identity-store.integration.test.ts b/src/test/identity-store.integration.test.ts index d2a48d73..618956b7 100644 --- a/src/test/identity-store.integration.test.ts +++ b/src/test/identity-store.integration.test.ts @@ -3,7 +3,6 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { test, expect } from "vitest"; @@ -26,6 +25,7 @@ import { saveGroupToken, type IdentitySlot, } from "../core/identity-store.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; @@ -50,7 +50,7 @@ async function mintGroupToken(groupPath: string): Promise { } function tempSlot(harness: string): { slot: IdentitySlot; dir: string } { - const dir = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-identity-test-")); + const dir = scratchDirectory("agent-comms-identity-test-"); return { slot: { harness, cwd: "/tmp/project", dir }, dir }; } diff --git a/src/test/machine-identity.unit.test.ts b/src/test/machine-identity.unit.test.ts index 7c7519f0..73fdf842 100644 --- a/src/test/machine-identity.unit.test.ts +++ b/src/test/machine-identity.unit.test.ts @@ -3,7 +3,6 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; import { @@ -13,6 +12,7 @@ import { saveMachineDisplayName, } from "../core/machine-identity.js"; import { loadOrCreateUserIdentity } from "../core/user-identity.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Mask isolating the permission bits from a stat mode's file-type bits. */ const PERMISSION_BITS_MASK = 0o777; @@ -20,7 +20,7 @@ const PERMISSION_BITS_MASK = 0o777; const OWNER_ONLY_RW_PERMISSIONS = 0o600; function tempDir(): string { - return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-machine-identity-")); + return scratchDirectory("agent-comms-machine-identity-"); } test("the machine identity is created once and reloaded unchanged from machine-identity.json", () => { diff --git a/src/test/machine-membership-trust.integration.test.ts b/src/test/machine-membership-trust.integration.test.ts index 55a19015..d7dca1d2 100644 --- a/src/test/machine-membership-trust.integration.test.ts +++ b/src/test/machine-membership-trust.integration.test.ts @@ -3,14 +3,13 @@ */ import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import { MeshStore } from "../core/mesh-store.js"; import { CommsTool } from "../core/tool.js"; import { shortId } from "../core/display-name.js"; import { freeLocalPort, realHubOverWs, TeardownStack } from "./hub-helpers.js"; import { wireTestTransport } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Short enough that a gossip re-advertisement fires within a test's own poll budget. */ const FAST_GOSSIP_INTERVAL_MS = 50; @@ -45,7 +44,7 @@ afterEach(async () => { /** One host's identity directory: its machine key and its account's user key live here, as they do in ~/.agent-comms. */ function hostDir(): string { - const dir = fs.mkdtempSync(path.join(tmpdir(), "machine-trust-host-")); + const dir = scratchDirectory("machine-trust-host-"); dirs.push(dir); return dir; } diff --git a/src/test/mcp-stdout-channel.integration.test.ts b/src/test/mcp-stdout-channel.integration.test.ts index 0c2622eb..831feb4b 100644 --- a/src/test/mcp-stdout-channel.integration.test.ts +++ b/src/test/mcp-stdout-channel.integration.test.ts @@ -5,7 +5,6 @@ import { afterEach, expect, it } from "vitest"; import * as child_process from "node:child_process"; import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import * as path from "node:path"; import * as readline from "node:readline"; import { fileURLToPath } from "node:url"; @@ -15,6 +14,7 @@ import { TeardownStack, unreachableHubUrl, } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const cleanups = new TeardownStack(); @@ -54,9 +54,7 @@ const ENTRIES: readonly Entry[] = ["mcp", "claude-code", "bare-command"]; it.each(ENTRIES)( "writes only JSON-RPC messages to stdout and puts the web UI banner on stderr (%s)", async (entry) => { - const slotDir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-mcp-test-"), - ); + const slotDir = scratchDirectory("agent-comms-mcp-test-"); cleanups.push(async () => { fs.rmSync(slotDir, { recursive: true, force: true }); }); diff --git a/src/test/membership-proof.unit.test.ts b/src/test/membership-proof.unit.test.ts index a994cb3b..be9207cf 100644 --- a/src/test/membership-proof.unit.test.ts +++ b/src/test/membership-proof.unit.test.ts @@ -1,9 +1,6 @@ /** * Unit tests for the membership proof a device gossips to show its user principal vouches for it: minted by that principal for that one device, carried as text, and verified by a receiver that trusts the principal. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; @@ -23,9 +20,10 @@ import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { DEVICE_MEMBER_CAPABILITY } from "../core/device-membership-verification.js"; import { encodeTokenText } from "../core/token-text.js"; import { randomId } from "../core/random-id.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; async function makeUser(): Promise>> { - const dir = fs.mkdtempSync(path.join(tmpdir(), "membership-proof-user-")); + const dir = scratchDirectory("membership-proof-user-"); return toIdentityPort(loadOrCreateUserIdentity({ dir })); } @@ -185,7 +183,7 @@ describe("membership proof", () => { }); it("verifies a machine's proof exactly as it verifies a principal's, and not against the principal", async () => { - const dir = fs.mkdtempSync(path.join(tmpdir(), "membership-proof-host-")); + const dir = scratchDirectory("membership-proof-host-"); const machine = await toIdentityPort(loadOrCreateMachineIdentity({ dir })); const user = await toIdentityPort(loadOrCreateUserIdentity({ dir })); const device = await toIdentityPort(generateIdentity()); @@ -215,7 +213,7 @@ describe("membership proof", () => { }); it("identifies the issuer a proof names without being told it beforehand", async () => { - const dir = fs.mkdtempSync(path.join(tmpdir(), "membership-proof-host-")); + const dir = scratchDirectory("membership-proof-host-"); const machine = await toIdentityPort(loadOrCreateMachineIdentity({ dir })); const device = await toIdentityPort(generateIdentity()); const proof = await mintMembershipProof({ @@ -236,7 +234,7 @@ describe("membership proof", () => { }); it("does not identify a proof for a different device", async () => { - const dir = fs.mkdtempSync(path.join(tmpdir(), "membership-proof-host-")); + const dir = scratchDirectory("membership-proof-host-"); const machine = await toIdentityPort(loadOrCreateMachineIdentity({ dir })); const device = await toIdentityPort(generateIdentity()); const other = await toIdentityPort(generateIdentity()); diff --git a/src/test/mesh-graph-trace-tool.e2e.test.ts b/src/test/mesh-graph-trace-tool.e2e.test.ts index e09ef1f2..33668992 100644 --- a/src/test/mesh-graph-trace-tool.e2e.test.ts +++ b/src/test/mesh-graph-trace-tool.e2e.test.ts @@ -1,8 +1,6 @@ /** * CommsTool's mesh_graph/mesh_trace actions (agent-comms#199) -- the tool-layer dispatch/formatting on top of MeshStore.meshGraph/meshTrace, mirroring web-url-tool-action.test.ts's own real-MeshStore-plus-CommsTool pattern. The underlying wire-level behaviour (real gossip-derived topology/peers, a real path.trace round trip, direct vs hub-relayed routing) is already proven end-to-end against WireMeshTransport directly in mesh-graph-trace.integration.test.ts; this file covers what's unique to the tool layer: notMeshBacked, empty-graph/unreachable-device formatting, and buildAction parsing. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { test, describe, expect } from "vitest"; import { MeshStore } from "../core/mesh-store.js"; @@ -10,6 +8,7 @@ import { CommsTool } from "../core/tool.js"; import { buildAction } from "../core/bridge.js"; import { FileStore } from "../core/store.js"; import { wireTestTransport } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** A device-id is SHA-256(public key) -- 32 raw bytes, 64 hex characters. */ const DEVICE_ID_BYTE_LENGTH = 32; @@ -17,9 +16,7 @@ const DEVICE_ID_BYTE_LENGTH = 32; const TEST_TIMEOUT_MS = 5000; function tempFileStore(): FileStore { - const root = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-mesh-graph-trace-tool-"), - ); + const root = scratchDirectory("agent-comms-mesh-graph-trace-tool-"); return new FileStore(root); } diff --git a/src/test/mesh-smoke.runner.ts b/src/test/mesh-smoke.runner.ts index 36f3f627..f7d3a416 100644 --- a/src/test/mesh-smoke.runner.ts +++ b/src/test/mesh-smoke.runner.ts @@ -9,6 +9,7 @@ import * as assert from "node:assert/strict"; import * as child_process from "node:child_process"; import * as net from "node:net"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const SMOKE_PORT = 19877; const SMOKE_HOST = "127.0.0.1"; @@ -34,8 +35,11 @@ function spawnPeer(name: string, actions: string): PeerHandle { const messages: TestMessage[] = []; const script = buildScript(name, actions); + // The child makes its identity directories under os.tmpdir(). Pointing that at a directory this process owns lets the exit cleanup in scratch-directory.helper.ts remove them even when the child is killed on a timeout, which a cleanup inside the child could not survive. + const childTmp = scratchDirectory("agent-comms-smoke-tmp-"); const child = child_process.spawn("node", ["-e", script], { stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, TMPDIR: childTmp, TEMP: childTmp, TMP: childTmp }, }); let buffer = ""; diff --git a/src/test/mesh-store-durable-send.integration.test.ts b/src/test/mesh-store-durable-send.integration.test.ts index bc4416fa..7cebce85 100644 --- a/src/test/mesh-store-durable-send.integration.test.ts +++ b/src/test/mesh-store-durable-send.integration.test.ts @@ -1,6 +1,4 @@ import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; import { describe, expect, it } from "vitest"; import { decode, cdeDecodeOptions } from "cbor2"; import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage"; @@ -19,12 +17,13 @@ import { } from "../core/index.js"; import { oplogDirFor } from "../core/identity-store.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; describe("public room-send durability", () => { it.each([undefined, false, true])( "records a notice only when durable is true (received %j)", async (durable) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mesh-durable-")); + const root = scratchDirectory("mesh-durable-"); const slot = { harness: "durable-test", cwd: root, dataDir: root }; const mesh = await createBridgeMesh(slot, { fetchLatestVersion: async () => undefined, diff --git a/src/test/naming.integration.test.ts b/src/test/naming.integration.test.ts index 4bf9b47c..68ef1139 100644 --- a/src/test/naming.integration.test.ts +++ b/src/test/naming.integration.test.ts @@ -3,8 +3,6 @@ */ import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import { MeshStore } from "../core/mesh-store.js"; import { CommsTool } from "../core/tool.js"; @@ -19,6 +17,7 @@ import { } from "./hub-helpers.js"; import { waitFor, wireTestTransportWithHub } from "./test-transport.js"; import type { WireMeshTransport } from "../core/wire-mesh-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** A device-id is a 64-character lowercase hex SHA-256 digest. */ const DEVICE_ID_HEX_LENGTH = 64; @@ -43,7 +42,7 @@ afterEach(async () => { }); function hostDir(): string { - const dir = fs.mkdtempSync(path.join(tmpdir(), "naming-host-")); + const dir = scratchDirectory("naming-host-"); dirs.push(dir); return dir; } diff --git a/src/test/petnames.unit.test.ts b/src/test/petnames.unit.test.ts index f58397aa..6e88a7cb 100644 --- a/src/test/petnames.unit.test.ts +++ b/src/test/petnames.unit.test.ts @@ -3,10 +3,10 @@ */ import * as fs from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { Petnames } from "../core/petnames.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Mask isolating the permission bits from a stat mode's file-type bits. */ const PERMISSION_BITS_MASK = 0o777; @@ -24,7 +24,7 @@ function petnamesFile(dir: string): string { } function tempDir(): string { - return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-petnames-")); + return scratchDirectory("agent-comms-petnames-"); } describe("Petnames", () => { diff --git a/src/test/principal-membership-trust.integration.test.ts b/src/test/principal-membership-trust.integration.test.ts index 1fddb2cf..ae427c3e 100644 --- a/src/test/principal-membership-trust.integration.test.ts +++ b/src/test/principal-membership-trust.integration.test.ts @@ -3,13 +3,12 @@ */ import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import { MeshStore } from "../core/mesh-store.js"; import { dmRoomPath } from "../core/room-path.js"; import { freeLocalPort, realHubOverWs, TeardownStack } from "./hub-helpers.js"; import { wireTestTransport } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Short enough that a gossip re-advertisement fires within a test's own poll budget. */ const FAST_GOSSIP_INTERVAL_MS = 50; @@ -45,7 +44,7 @@ afterEach(async () => { }); function userDir(): string { - const dir = fs.mkdtempSync(path.join(tmpdir(), "principal-trust-user-")); + const dir = scratchDirectory("principal-trust-user-"); dirs.push(dir); return dir; } diff --git a/src/test/room-join-admission.integration.test.ts b/src/test/room-join-admission.integration.test.ts index 67885c57..65d107c0 100644 --- a/src/test/room-join-admission.integration.test.ts +++ b/src/test/room-join-admission.integration.test.ts @@ -4,9 +4,7 @@ * Deliberately unit-level, not a full two-peer integration test: MeshStore's own legacy full-state-sync (still active per P3.3's "both paths coexist" transition) makes any two mesh-connected stores instantly aware of every room the moment they connect, so a genuinely two-peer test can never actually exercise the "room I've never heard of" case this code exists for -- exactly the scenario section 4 of the design doc names as a real regression, not something a live two-store test can reach today. Testing the handler and the remote-join path directly, against fakes, is the only way to exercise this code before P3.8 retires the legacy path. */ -import * as fs from "node:fs"; import * as path from "node:path"; -import { tmpdir } from "node:os"; import { describe, it, expect } from "vitest"; import type { IncomingManageRequest, @@ -33,6 +31,7 @@ import { loadOrCreateMachineIdentity } from "../core/machine-identity.js"; import { openAccountLedger } from "../core/account-ledger-store.js"; import type { ConnectionHandle, MeshTransport } from "../core/transport.js"; import { wireTestTransport } from "./test-transport.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const DEVICE_ID_HEX_LENGTH = 64; const TOKEN_TTL_MS = 60_000; @@ -179,15 +178,13 @@ describe("joinRoom (requester side, remote path)", () => { const slot: IdentitySlot = { harness: "test", cwd: "req", - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-join-remote-")), + dir: scratchDirectory("agent-comms-join-remote-"), }; const identity = loadOrCreateIdentity(slot); store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); const identityPort = await toIdentityPort(identity); const userIdentityOptions = { - dir: fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-test-user-identity-"), - ), + dir: scratchDirectory("agent-comms-test-user-identity-"), }; const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); store.setIdentity({ diff --git a/src/test/room-lifecycle-bare-name.unit.test.ts b/src/test/room-lifecycle-bare-name.unit.test.ts index 4c901810..b6c5b996 100644 --- a/src/test/room-lifecycle-bare-name.unit.test.ts +++ b/src/test/room-lifecycle-bare-name.unit.test.ts @@ -2,9 +2,6 @@ * Direct, DI-based unit tests for how RoomLifecycle resolves a bare room name (agent-comms#246): listRooms surfaces a room discovered through another device's gossiped hosted-room advert under its bare `name`, so every room action that accepts a name has to resolve against the same discovered rooms, not only against rooms already replicated into this store's own map. */ import { describe, expect, it, vi } from "vitest"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -20,6 +17,7 @@ import { type RoomLifecycleDeps, } from "../core/room-lifecycle.js"; import type { Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; @@ -68,7 +66,7 @@ async function makeHarness(): Promise { const selfId = bytesToHex(Uint8Array.from(self.deviceId)); const hostAId = bytesToHex(Uint8Array.from(hostA.deviceId)); const hostBId = bytesToHex(Uint8Array.from(hostB.deviceId)); - const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-bare-name-test-")); + const slotDir = scratchDirectory("room-bare-name-test-"); const slot = { harness: "test", cwd: "room-bare-name", dir: slotDir }; loadOrCreateIdentity(slot); diff --git a/src/test/room-lifecycle-membership.unit.test.ts b/src/test/room-lifecycle-membership.unit.test.ts index 83a6610d..0141833a 100644 --- a/src/test/room-lifecycle-membership.unit.test.ts +++ b/src/test/room-lifecycle-membership.unit.test.ts @@ -2,9 +2,6 @@ * Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- leaveRoom/leaveRemoteRoom, inviteToRoom, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts (createRoom/listRooms/joinRoom) and room-lifecycle-remote.test.ts (refreshRoomMembers/requestDmAccess/getRoom/revokeMemberGrant/declineInvite) to stay under this repo's max-lines cap. All three files share an identical preamble (helpers, fakes, makeHarness) by necessity of the split -- see room-lifecycle.test.ts's own header for the full rationale (real identities/tokens, not opaque placeholders, since this class genuinely mints and revokes capability tokens). */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -27,6 +24,7 @@ import { type RoomLifecycleDeps, } from "../core/room-lifecycle.js"; import type { AgentIdentity, Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; @@ -120,9 +118,7 @@ interface Harness { async function makeHarness(): Promise { const ids = await makeIdentities(); - const slotDir = fs.mkdtempSync( - path.join(tmpdir(), "room-lifecycle-membership-test-"), - ); + const slotDir = scratchDirectory("room-lifecycle-membership-test-"); const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir }; // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. loadOrCreateIdentity(slot); diff --git a/src/test/room-lifecycle-remote.unit.test.ts b/src/test/room-lifecycle-remote.unit.test.ts index 3b06ae85..6ec824f1 100644 --- a/src/test/room-lifecycle-remote.unit.test.ts +++ b/src/test/room-lifecycle-remote.unit.test.ts @@ -2,9 +2,6 @@ * Direct, DI-based unit tests for RoomLifecycle's own remote-request-sending half -- refreshRoomMembers, requestDmAccess, getRoom, revokeMemberGrant, and declineInvite, moved here from room-lifecycle.test.ts/room-lifecycle-membership.test.ts to stay under this repo's max-lines cap after later gap-closing passes. See room-lifecycle.test.ts for createRoom/listRooms/joinRoom/joinRemoteRoom, and room-lifecycle-membership.test.ts for leaveRoom/leaveRemoteRoom/inviteToRoom/kickFromRoom/destroyRoom -- and either file's own header for the full rationale. Real identities and real minted tokens throughout (not opaque placeholders): RoomLifecycle genuinely mints capability tokens and parses roomJoinOkSchema/roomMembersOkSchema's own structural shape (a real COSE_Sign1 tuple, though never signature-verified by this class), so an arbitrary placeholder string fails the schema outright where room-messaging.test.ts's forwarding-only case could get away with one. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -27,6 +24,7 @@ import { type RoomLifecycleDeps, } from "../core/room-lifecycle.js"; import type { AgentIdentity, Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; @@ -120,7 +118,7 @@ interface Harness { async function makeHarness(): Promise { const ids = await makeIdentities(); - const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-lifecycle-test-")); + const slotDir = scratchDirectory("room-lifecycle-test-"); const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir }; // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. loadOrCreateIdentity(slot); diff --git a/src/test/room-lifecycle.unit.test.ts b/src/test/room-lifecycle.unit.test.ts index 39449c86..79eb9240 100644 --- a/src/test/room-lifecycle.unit.test.ts +++ b/src/test/room-lifecycle.unit.test.ts @@ -2,9 +2,6 @@ * Direct, DI-based unit tests for RoomLifecycle -- room CRUD and the requester's own outbound half of the wire protocol, split across three files to stay under this repo's max-lines cap: this file covers createRoom, listRooms, and joinRoom/joinRemoteRoom. See room-lifecycle-remote.test.ts for refreshRoomMembers/requestDmAccess and room-lifecycle-membership.test.ts for getRoom/leaveRoom/leaveRemoteRoom/inviteToRoom/declineInvite/revokeMemberGrant/kickFromRoom/destroyRoom (both moved out to rebalance line counts after later gap-closing passes). Real identities and real minted tokens throughout (not opaque placeholders): RoomLifecycle genuinely mints capability tokens and parses roomJoinOkSchema/roomMembersOkSchema's own structural shape (a real COSE_Sign1 tuple, though never signature-verified by this class), so an arbitrary placeholder string fails the schema outright where room-messaging.test.ts's forwarding-only case could get away with one. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken, @@ -28,6 +25,7 @@ import { type RoomLifecycleDeps, } from "../core/room-lifecycle.js"; import type { AgentIdentity, Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const TOKEN_TTL_MS = 60_000; @@ -121,7 +119,7 @@ interface Harness { async function makeHarness(): Promise { const ids = await makeIdentities(); - const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-lifecycle-test-")); + const slotDir = scratchDirectory("room-lifecycle-test-"); const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir }; // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. loadOrCreateIdentity(slot); diff --git a/src/test/room-messaging-durable-send.unit.test.ts b/src/test/room-messaging-durable-send.unit.test.ts index 4c0b7435..d29bb9b1 100644 --- a/src/test/room-messaging-durable-send.unit.test.ts +++ b/src/test/room-messaging-durable-send.unit.test.ts @@ -2,9 +2,7 @@ * sendRoomMessage's opt-in durable flag (P5's mint-and-append half, agent-comms#50, wired via recordRoomSendNotice from #135): a caller that passes durable: true also gets the message recorded as a room-notice in the sender's own oplog, verifiable end to end via a real verifyRoomNotice, not just an opaque append. Real identities and a real minted token throughout, matching room-lifecycle.test.ts's own established convention for this class of test -- recordRoomSendNotice genuinely mints and signs, so an opaque placeholder token would fail the mint outright. */ import { describe, expect, it } from "vitest"; -import * as fs from "node:fs"; import * as path from "node:path"; -import { tmpdir } from "node:os"; import { decode as cborDecode, cdeDecodeOptions } from "cbor2"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; @@ -24,6 +22,7 @@ import { type RoomMessagingDeps, } from "../core/room-messaging.js"; import type { Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; const NO_DELEGATIONS_REMAINING = 0; const MINUTES_PER_HOUR = 60; @@ -51,9 +50,7 @@ async function makeHarness() { if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); // saveRoomToken writes into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying (room-lifecycle.test.ts's own established convention for this exact precondition). - const slotDir = fs.mkdtempSync( - path.join(tmpdir(), "room-messaging-durable-send-test-"), - ); + const slotDir = scratchDirectory("room-messaging-durable-send-test-"); const slot = { harness: "test", cwd: "room-messaging", dir: slotDir }; loadOrCreateIdentity(slot); saveRoomToken(slot, roomPath, verdict.token); diff --git a/src/test/room-protocol-notify.unit.test.ts b/src/test/room-protocol-notify.unit.test.ts index d60f6990..1bcf6f1b 100644 --- a/src/test/room-protocol-notify.unit.test.ts +++ b/src/test/room-protocol-notify.unit.test.ts @@ -2,9 +2,6 @@ * Direct, DI-based unit tests for RoomProtocol's handleRoomNotify (P3.8's directed-delivery replacement for informational events, agent-comms#48) -- moved into its own file to stay under the repo's max-lines cap once room-protocol.test.ts and room-protocol-admission.test.ts were already at capacity. Real identities and real minted tokens throughout, matching those two files' own established convention (handleRoomNotify genuinely verifies tokens cryptographically, so an opaque placeholder string fails the mint/verify outright). */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -21,6 +18,7 @@ import { ownerNamedRoomPath } from "../core/room-path.js"; import { RoomProtocol, type RoomProtocolDeps } from "../core/room-protocol.js"; import { randomId } from "../core/random-id.js"; import type { DeliveryEvent, Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Far longer than any test runs, so a pending join is never expired unless a test says so. */ const JOIN_DECISION_TIMEOUT_MS = 600_000; @@ -93,9 +91,7 @@ interface Harness { async function makeHarness(): Promise { const ids = await makeIdentities(); - const slotDir = fs.mkdtempSync( - path.join(tmpdir(), "room-protocol-notify-test-"), - ); + const slotDir = scratchDirectory("room-protocol-notify-test-"); const slot = { harness: "test", cwd: "room-protocol-notify", dir: slotDir }; loadOrCreateIdentity(slot); const queueDelivery = diff --git a/src/test/room-protocol.helper.ts b/src/test/room-protocol.helper.ts index e13f5d6c..8f7c7b53 100644 --- a/src/test/room-protocol.helper.ts +++ b/src/test/room-protocol.helper.ts @@ -2,9 +2,7 @@ * The shared DI harness every RoomProtocol unit-test file builds against: real identities and real minted tokens (token verification here is genuinely cryptographic, unlike room-messaging.ts's sending side, which never inspects a token's own structure), a fake transport whose sendRoomRequest each test drives, and a fake delivery engine whose calls each test asserts on. Extracted so the three RoomProtocol test files share one harness rather than each carrying its own copy of it. */ import { vi } from "vitest"; -import * as fs from "node:fs"; import * as path from "node:path"; -import { tmpdir } from "node:os"; import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -20,6 +18,7 @@ import { toIdentityPort } from "../core/wire-mesh-identity.js"; import { RoomProtocol, type RoomProtocolDeps } from "../core/room-protocol.js"; import { randomId } from "../core/random-id.js"; import type { AgentIdentity, Room } from "../core/types.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Far longer than any test runs, so a pending join is never expired unless a test says so. */ export const JOIN_DECISION_TIMEOUT_MS = 600_000; @@ -131,7 +130,7 @@ export async function makeHarness(): Promise { const systemClock = createSystemClock(); let clockOffsetMs = 0; const clock = { now: () => systemClock.now() + clockOffsetMs }; - const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-protocol-test-")); + const slotDir = scratchDirectory("room-protocol-test-"); const slot = { harness: "test", cwd: "room-protocol", dir: slotDir }; // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. loadOrCreateIdentity(slot); diff --git a/src/test/room-send-retry.integration.test.ts b/src/test/room-send-retry.integration.test.ts index b95bc4f7..97e97138 100644 --- a/src/test/room-send-retry.integration.test.ts +++ b/src/test/room-send-retry.integration.test.ts @@ -2,9 +2,6 @@ * Integration test for issue #28's own acceptance behaviour, re-verified against P3.5's directed fan-out: a room message sent while its recipient is offline must still reach the recipient once it reconnects. Unlike the legacy full-state-sync this replaces, a directed room.send to a disconnected member fails immediately rather than eventually converging, so the fan-out queues it for retry (pendingRoomSends) and flushes that queue the moment the member's connection is (re)established (handlePeerConnected). Replaces downtime-replay.integration.test.ts, whose own scenario relied on deliveryQueues/applyStateSync -- machinery this fan-out no longer uses for message delivery. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test, expect } from "vitest"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; @@ -25,6 +22,7 @@ import type { DeliveryEvent } from "../core/types.js"; import type { PeerIdentity } from "../core/identity.js"; import { ownerNamedRoomPath } from "../core/room-path.js"; import { freeLocalPort } from "./hub-helpers.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** How many polling attempts {@link waitFor} makes before failing. */ const WAIT_FOR_MAX_ATTEMPTS = 20; @@ -62,7 +60,7 @@ async function makePeer( }), ); const userIdentityOptions = { - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-user-identity-")), + dir: scratchDirectory("agent-comms-test-user-identity-"), }; const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); store.setIdentity({ @@ -101,12 +99,12 @@ async function waitFor( async function main(): Promise { const port = await freeLocalPort(); - const dir = fs.mkdtempSync(path.join(tmpdir(), "agent-comms-send-retry-")); + const dir = scratchDirectory("agent-comms-send-retry-"); const slot: IdentitySlot = { harness: "pi", cwd: "/tmp/project", dir }; const slotA: IdentitySlot = { harness: "claude-code", cwd: "/tmp/a", - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-send-retry-a-")), + dir: scratchDirectory("agent-comms-send-retry-a-"), }; // A is a normal ephemeral bridge that stays up throughout. diff --git a/src/test/room-token-store.unit.test.ts b/src/test/room-token-store.unit.test.ts index badf250e..e03c5953 100644 --- a/src/test/room-token-store.unit.test.ts +++ b/src/test/room-token-store.unit.test.ts @@ -2,9 +2,6 @@ * Unit tests for per-room capability token persistence (core/identity-store). */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test, expect } from "vitest"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; import { @@ -15,11 +12,10 @@ import { releaseIdentityLock, type IdentitySlot, } from "../core/identity-store.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; function tempSlot(harness: string): { slot: IdentitySlot; dir: string } { - const dir = fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-room-token-test-"), - ); + const dir = scratchDirectory("agent-comms-room-token-test-"); return { slot: { harness, cwd: "/tmp/project", dir }, dir }; } diff --git a/src/test/scratch-directory.helper.ts b/src/test/scratch-directory.helper.ts new file mode 100644 index 00000000..b4020016 --- /dev/null +++ b/src/test/scratch-directory.helper.ts @@ -0,0 +1,39 @@ +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; + +const scratchDirectories = new Set(); + +/** + * Creates a fresh directory under the system temporary directory and tracks it for removal. + * + * Every test that needs a throwaway directory goes through here instead of calling `fs.mkdtempSync` itself, because nothing else removes what a test run leaves in the system temporary directory, and a suite that creates directories per test accumulates them across runs without bound. + * + * A tracked directory is removed by `removeScratchDirectory`, by `removeAllScratchDirectories` (run after each test file by `scratch-directory.setup.ts`) or when the process exits, whichever comes first. + * @param prefix - Prefix of the directory name; `fs.mkdtempSync` appends six random characters. + * @returns Absolute path of the new, empty directory. + */ +export function scratchDirectory(prefix: string): string { + const dir = fs.mkdtempSync(path.join(tmpdir(), prefix)); + scratchDirectories.add(dir); + return dir; +} + +/** + * Removes one directory made by `scratchDirectory`, with everything inside it, and stops tracking it. A directory that is already gone is not an error, so a test that cleans up after itself and the file-level cleanup can both run. + * @param dir - A path returned by `scratchDirectory`. + */ +export function removeScratchDirectory(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); + scratchDirectories.delete(dir); +} + +/** Removes every directory `scratchDirectory` has made in this process that is still tracked. */ +export function removeAllScratchDirectories(): void { + for (const dir of [...scratchDirectories]) { + removeScratchDirectory(dir); + } +} + +// Backstop for a process that ends without running the file-level cleanup (a test file that throws while loading, a worker stopped between files). The exit event does not run for a process killed by a signal, which is why the file-level cleanup exists as well. +process.once("exit", removeAllScratchDirectories); diff --git a/src/test/scratch-directory.setup.ts b/src/test/scratch-directory.setup.ts new file mode 100644 index 00000000..47026f86 --- /dev/null +++ b/src/test/scratch-directory.setup.ts @@ -0,0 +1,7 @@ +// Vitest setup file: removes the directories a test file made through `scratchDirectory` once the file's tests have finished. It is registered before the test file's own hooks, and vitest runs after-hooks in reverse order of registration, so this runs after the file's own `afterAll` hooks have shut down whatever was using those directories. +import { afterAll } from "vitest"; +import { removeAllScratchDirectories } from "./scratch-directory.helper.js"; + +afterAll(() => { + removeAllScratchDirectories(); +}); diff --git a/src/test/scratch-directory.unit.test.ts b/src/test/scratch-directory.unit.test.ts new file mode 100644 index 00000000..43474af9 --- /dev/null +++ b/src/test/scratch-directory.unit.test.ts @@ -0,0 +1,106 @@ +/** + * Unit tests for the scratch-directory helper every test uses for throwaway directories: creation under the system temporary directory, explicit removal, removal of everything tracked, and the exit backstop that removes what a process leaves behind. + */ +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + removeAllScratchDirectories, + removeScratchDirectory, + scratchDirectory, +} from "./scratch-directory.helper.js"; + +const HELPER_PATH = fileURLToPath( + new URL("./scratch-directory.helper.ts", import.meta.url), +); + +describe("scratchDirectory", () => { + it("creates an empty directory under the system temporary directory with the given prefix", () => { + const dir = scratchDirectory("scratch-helper-create-"); + + expect(path.dirname(dir)).toBe(tmpdir()); + expect(path.basename(dir)).toMatch(/^scratch-helper-create-.+/); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + it("gives each call its own directory", () => { + const first = scratchDirectory("scratch-helper-distinct-"); + const second = scratchDirectory("scratch-helper-distinct-"); + + expect(first).not.toBe(second); + }); +}); + +describe("removeScratchDirectory", () => { + it("removes the directory with everything inside it", () => { + const dir = scratchDirectory("scratch-helper-remove-"); + fs.mkdirSync(path.join(dir, "nested")); + fs.writeFileSync(path.join(dir, "nested", "file.txt"), "content"); + + removeScratchDirectory(dir); + + expect(fs.existsSync(dir)).toBe(false); + }); + + it("accepts a directory that is already gone", () => { + const dir = scratchDirectory("scratch-helper-twice-"); + removeScratchDirectory(dir); + + expect(() => { + removeScratchDirectory(dir); + }).not.toThrow(); + }); +}); + +describe("removeAllScratchDirectories", () => { + it("removes every directory still tracked and leaves other directories alone", () => { + const tracked = scratchDirectory("scratch-helper-all-a-"); + const alsoTracked = scratchDirectory("scratch-helper-all-b-"); + const untracked = fs.mkdtempSync( + path.join(tmpdir(), "scratch-helper-untracked-"), + ); + + try { + removeAllScratchDirectories(); + + expect(fs.existsSync(tracked)).toBe(false); + expect(fs.existsSync(alsoTracked)).toBe(false); + expect(fs.existsSync(untracked)).toBe(true); + } finally { + fs.rmSync(untracked, { recursive: true, force: true }); + } + }); +}); + +describe("process exit", () => { + it("removes the directories a process created and never cleaned up", () => { + const home = scratchDirectory("scratch-helper-exit-"); + const script = [ + `import(${JSON.stringify(HELPER_PATH)}).then((helper) => {`, + ` process.stdout.write(helper.scratchDirectory("scratch-helper-orphan-"));`, + `});`, + ].join("\n"); + + const result = childProcess.spawnSync( + process.execPath, + ["--import", "tsx", "-e", script], + { + encoding: "utf8", + env: { ...process.env, TMPDIR: home, TEMP: home, TMP: home }, + }, + ); + + expect(result.status).toBe(0); + const orphan = result.stdout; + expect(path.dirname(orphan)).toBe(home); + expect(fs.existsSync(orphan)).toBe(false); + expect( + fs + .readdirSync(home) + .filter((name) => name.startsWith("scratch-helper-orphan-")), + ).toEqual([]); + }); +}); diff --git a/src/test/test-transport.ts b/src/test/test-transport.ts index 83a62637..b965530a 100644 --- a/src/test/test-transport.ts +++ b/src/test/test-transport.ts @@ -1,8 +1,5 @@ /** Wires a real WireMeshTransport plus a persisted identity slot onto a freshly constructed MeshStore -- the same setTransport()/setIdentity() calls every production bridge makes immediately after construction (via createBridgeMesh). MeshStore has no default transport or identity, so every test that constructs one needs this (or an equivalent explicit wiring) before init(), createRoom(), or any other transport- or identity-using method runs. */ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { tmpdir } from "node:os"; import { WireMeshTransport } from "../core/wire-mesh-transport.js"; import type { TransportEvents } from "../core/transport.js"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; @@ -27,6 +24,7 @@ import { import { openAccountLedger } from "../core/account-ledger-store.js"; import { nanoid } from "../core/nanoid.js"; import type { MeshStore } from "../core/mesh-store.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** Short enough to keep generated test identity slots readable, long enough that two concurrent test runs won't collide on the same throwaway cwd. */ const TEST_IDENTITY_CWD_ID_LENGTH = 8; @@ -67,20 +65,18 @@ async function wireTransportInternal( const resolvedSlot: IdentitySlot = slot ?? { harness: "test", cwd: nanoid(TEST_IDENTITY_CWD_ID_LENGTH), - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-identity-")), + dir: scratchDirectory("agent-comms-test-identity-"), }; const identity = identityLoader(resolvedSlot); // A fresh throwaway directory per call, matching resolvedSlot's own default: each test MeshStore represents a separate device belonging to a separate person, so it needs its own user-principal identity, never the real machine-wide ~/.agent-comms/user-identity.json a production bridge shares. A caller that needs to know this store's own principal device-id ahead of time (agent-comms#187's own principal-keyed admission tests) passes an explicit userIdentityOptions naming a directory it already loaded itself, rather than this store minting one it can never be told about afterwards. const resolvedUserIdentityOptions: Readonly = userIdentityOptions ?? { - dir: fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-test-user-identity-"), - ), + dir: scratchDirectory("agent-comms-test-user-identity-"), }; const userIdentity = loadOrCreateUserIdentity(resolvedUserIdentityOptions); const resolvedMachineIdentityOptions: Readonly = machineIdentityOptions ?? { - dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-machine-")), + dir: scratchDirectory("agent-comms-test-machine-"), }; const machineIdentity = loadOrCreateMachineIdentity( resolvedMachineIdentityOptions, diff --git a/src/test/user-identity.unit.test.ts b/src/test/user-identity.unit.test.ts index 2379d4ab..64d6864d 100644 --- a/src/test/user-identity.unit.test.ts +++ b/src/test/user-identity.unit.test.ts @@ -4,7 +4,6 @@ import * as fs from "node:fs"; import type * as FsModule from "node:fs"; -import { tmpdir } from "node:os"; import path from "node:path"; import { test, expect, vi, afterEach } from "vitest"; import { @@ -18,6 +17,7 @@ import { import { CERTIFICATE_VALIDITY_MS } from "../core/identity.js"; import { generateWriterNonce } from "../core/account-ledger-crypto.js"; import { randomId } from "../core/random-id.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; // node:fs's linkSync is wrapped (not replaced) so every test gets the real filesystem by default; only the one race test below overrides it, via mockImplementationOnce, to simulate a concurrent writer winning the exclusive create. vi.spyOn cannot target an ESM named export directly ("Module namespace is not configurable"), so the wrap has to happen at vi.mock time instead. vi.mock("node:fs", async (importOriginal) => { @@ -35,7 +35,7 @@ class ExistsError extends Error { } function tempDir(): string { - return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-user-identity-test-")); + return scratchDirectory("agent-comms-user-identity-test-"); } function identityFile(dir: string): string { diff --git a/src/test/user-principal-mints-device-grant.unit.test.ts b/src/test/user-principal-mints-device-grant.unit.test.ts index a4177990..2fa48073 100644 --- a/src/test/user-principal-mints-device-grant.unit.test.ts +++ b/src/test/user-principal-mints-device-grant.unit.test.ts @@ -4,9 +4,6 @@ * The capability/scope minted here (`room:member`, scope kind `group`) is a syntactically valid probe only, borrowed from the one capability string already proven to mint and verify elsewhere in this codebase -- the real device-to-user membership grant shape (what capability and scope a device actually presents to prove "the user principal admitted me") is agent-comms#161's own scope, deliberately not decided here. */ -import * as fs from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test, expect } from "vitest"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { @@ -18,14 +15,13 @@ import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import { generateIdentity } from "../core/identity.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; import { randomId } from "../core/random-id.js"; +import { scratchDirectory } from "./scratch-directory.helper.js"; /** The probe grant's expiry window. */ const TOKEN_TTL_MS = 60_000; function tempDir(): string { - return fs.mkdtempSync( - path.join(tmpdir(), "agent-comms-user-principal-mint-test-"), - ); + return scratchDirectory("agent-comms-user-principal-mint-test-"); } test("the user principal mints a capability token bearing a device it owns", async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 7c2fb166..3957a0d5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,10 @@ export default defineConfig({ environment: "node", fileParallelism: false, testTimeout: 30_000, - setupFiles: ["src/bridges/user/web/frontend/test/vitest-setup.ts"], + setupFiles: [ + "src/bridges/user/web/frontend/test/vitest-setup.ts", + "src/test/scratch-directory.setup.ts", + ], exclude: [ ...defaultExclude, // Compiled output of the *.test.ts sources vitest already runs -- vitest 5's defaultExclude no longer excludes dist/ on its own.