Make MCP socket tests portable to Windows - #348
Conversation
cd70eb9 to
6abd214
Compare
The socket tests each rolled their own tmp-path helper and assumed a POSIX filesystem, so the Windows CI run could never go green on them. temporarySocketPath() replaces those ad hoc helpers with one shared, truly-unique path (pid + process.hrtime, plus a named-pipe form on Windows) that survives Vitest's per-file module resets, unlike a plain in-module counter. While porting the ownership tests, a mislabeled describe block turned up: four unrelated behaviors sat under the title "fails to start if the parent directory is a file", a scenario nothing actually tested. The block is renamed and the real regression test for that ENOTDIR path now exists, inside the POSIX-only "real socket file behavior" block, since it builds a filesystem path directly and a Windows named pipe fails with a generic EACCES instead. Also folds two pre-existing, differently-spelled "skip on Windows" test helpers (unixOnly, itUnlessWin32) into the same platformGates.ts module this work introduces, and switches mcpSocketServer.ts to the shared isWindows() instead of re-deriving process.platform === 'win32'. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6abd214 to
ad9222c
Compare
| let sidecarPath: string; | ||
| beforeEach(() => { | ||
| sidecarPath = makeTempSidecarPath(); | ||
| sidecarPath = temporarySidecarPath(); |
There was a problem hiding this comment.
🚫 This swap changes what path.dirname(sidecarPath) means, and the afterEach right below (line 30) still recursively deletes it.
The old makeTempSidecarPath() was path.join(fs.mkdtempSync(...), 'mcp.owner.json'), so dirname was a dedicated temp directory and rmSync(dirname, { recursive: true, force: true }) was safe. The new temporarySidecarPath() returns path.join(os.tmpdir(), 'jasper-mcp-tempfile-<hex>.json') — no intermediate directory — so dirname is now os.tmpdir() itself, and that hook is effectively fs.rmSync(os.tmpdir(), { recursive: true, force: true }).
Three hooks are in this state: lines 30 and 106 here, and mcpServerTreeProvider.test.ts:38. Concretely, on Linux CI that's rm -rf /tmp after each of those tests, and on macOS the same for /var/folders/…/T. Since vitest runs files in parallel workers, it will delete the sockets and sidecars other test files are actively using mid-run — and force: true plus the surrounding try {} catch {} means it fails silently, so the symptom shows up as unrelated flaky failures elsewhere rather than as an error here.
Since the file lives directly in os.tmpdir() now, could these hooks just fs.rmSync(sidecarPath, { force: true }) (no recursive)? That matches what the helper actually creates.
Related: it('creates the parent directory if it does not exist') (line 44) builds nested as path.join(path.dirname(sidecarPath), 'sub', 'mcp.owner.json'), which is now a fixed os.tmpdir()/sub shared by every run instead of a per-test path — so that test loses its isolation too. Worth giving it its own unique directory?
| let sidecarPath: string; | ||
| beforeEach(() => { | ||
| sidecarPath = makeTempSidecarPath(); | ||
| sidecarPath = temporarySidecarPath(); |
There was a problem hiding this comment.
🚫 Same issue as my comment on mcpOwnerSidecar.test.ts:26 — the afterEach below now recursively removes os.tmpdir(), since temporarySidecarPath() no longer nests the file in its own directory.
| * | ||
| * @param {string} file - The path to the file to delete. | ||
| */ | ||
| function safelyRemoveFile(file: string): void { |
There was a problem hiding this comment.
💭 The doc on withTemporaryFileDo promises the file is "deleted afterward regardless of success or failure", but this swallows the failure — so what's actually guaranteed is an attempt, and a leaked file surfaces only as a console.error line in the vitest output that nobody reads.
Is the swallow load-bearing for a case you hit? If the file is always created by createTemporaryFile() a line earlier, unlinkSync failing means something genuinely surprising happened (the consumer replaced the path with a directory, a permissions change), which seems worth failing on rather than logging. Or, if you'd rather keep the test green, soften the doc to say cleanup is best-effort so a future reader doesn't lean on the stronger promise?
Also, is console.error the right channel here? Everything else in the client routes through appendSysadmin, and in a test helper it just adds unattributable noise to the run output — there's no test name attached to it.
| * | ||
| * @returns An absolute path to a non-existent `.json` file in the OS's temp directory. | ||
| */ | ||
| export function temporarySidecarPath() { |
There was a problem hiding this comment.
💭 This one is about sidecars, not sockets, and two of its three callers have no socket in sight: mcpOwnerSidecar.test.ts tests the sidecar reader/writer, and mcpServerTreeProvider.test.ts tests ownership rendering in the tree. Both now reach into support/socket for it, which reads oddly at the import site.
Would support/sidecar.ts (or just putting it next to temporaryFilePath in support/file.ts, since that's all it delegates to) be a better home? The doc would get more accurate as a side effect too — "metadata or state alongside a temporarySocketPath()" describes only the mcpSocketOwnership usage.
| * test runner's per-file module resets. | ||
| **/ | ||
| export function temporarySocketPath(): string { | ||
| const socketName = `jasper-mcp-temp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`; |
There was a problem hiding this comment.
nit: jasper-mcp-temp- (sockets) and jasper-mcp-tempfile- (from temporaryFileName()) are near-identical prefixes, and file.ts says its prefix is there "for easy identification". In practice ls /tmp | grep jasper-mcp-temp matches both, so the one thing the prefix is for doesn't quite work. Worth making this one jasper-mcp-socket-?
| @@ -0,0 +1,14 @@ | |||
| import { describe, it } from 'vitest'; | |||
|
|
|||
| const isPosix = process.platform === 'linux' || process.platform === 'darwin'; | |||
There was a problem hiding this comment.
That's a fair point, and I'd rather have the explicit allow-list than silently start running these somewhere we haven't validated — thanks for spelling out the reasoning.
💭 Given that's the intent, should the names say so? isPosix / onPosix* claim a property of the platform (freebsd and aix are POSIX, and would be excluded here), whereas what you're actually encoding is "the platforms we know socket behavior on". Something like isSupportedPosixPlatform with onSupportedPosixIt/Describe — or keeping onPosix* but having the doc say "Linux/macOS only; other POSIX platforms are excluded because we haven't validated socket behavior there" — would put your rationale where the next reader finds it instead of in this thread.
What
Makes the MCP socket tests runnable on Windows, and closes two gaps found while
porting them.
The socket tests each rolled their own tmp-path helper and assumed a POSIX
filesystem, so the Windows CI run could never go green on them.
temporarySocketPath()replaces those ad hoc helpers with one shared,truly-unique path (pid +
process.hrtime, plus a named-pipe form on Windows)that survives Vitest's per-file module resets, unlike a plain in-module counter.
While porting the ownership tests, a mislabeled
describeblock turned up: fourunrelated behaviors sat under the title "fails to start if the parent directory
is a file", a scenario nothing actually tested. The block is renamed and the
real regression test for that
ENOTDIRpath now exists, inside the POSIX-only"real socket file behavior" block, since it builds a filesystem path directly
and a Windows named pipe fails with a generic
EACCESinstead.Also folds two pre-existing, differently-spelled "skip on Windows" test helpers
(
unixOnly,itUnlessWin32) into the sameplatformGates.tsmodule this workintroduces, and switches
mcpSocketServer.tsto the sharedisWindows()instead of re-deriving
process.platform === 'win32'.Testing
npm run lint,npm run format:check,npm run compile, andnpm testpasslocally on macOS. The Windows Health Check CI run is the one that matters here.
🤖 Generated with Claude Code