fix(windows): make the server and test suite work on Windows - #169
fix(windows): make the server and test suite work on Windows#169freema wants to merge 9 commits into
Conversation
geckodriver only searches the Program Files directories and HKEY_LOCAL_MACHINE for Firefox, so it cannot see an install made without administrator rights: that one lands in %LOCALAPPDATA%\Mozilla Firefox and registers under HKCU. Launching then fails with "Expected browser binary location, but unable to find binary in default location" even though Firefox is installed and working. Resolve the binary ourselves and pass it as moz:firefoxOptions.binary. Candidates are probed in geckodriver's own order (Program Files before the per-user location) so machines where it already works keep resolving to the same binary, then PATH, then the App Paths registry key. When nothing is found, replace geckodriver's message with one that names --firefox-path. get_firefox_info now reports the detected path so the resolution is visible when debugging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three failures a Windows contributor hits before writing any code: - Git for Windows defaults to core.autocrlf=true, giving the working tree CRLF endings, which collide with Prettier's `endOfLine: "lf"`. On a clean checkout `npm run format:check` reported all 59 source files as unformatted, and `npm run format` would have rewritten every one of them. A .gitattributes now pins LF checkout, keeping the repository byte-identical across platforms. - `npm run clean` shelled out to `rm -rf`, which does not exist in the cmd.exe that npm runs scripts through. That also broke prepublishOnly, so `npm publish` could not run on Windows at all. - `npm run build:mcpb` shelled out to `mkdir -p` for the same reason. Also drops the test:integration:win entry; the runner it points at is removed in the following commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Integration tests were excluded on Windows because vitest hung when forking
tests that spawn Firefox (issue #33), and a standalone runner stood in for
them. That hang no longer reproduces on vitest 4: with the exclusion lifted
all 7 integration files (54 tests) pass. Remove both the exclusion and the
runner rather than keep two suites that drift apart — the runner had already
drifted, asserting on `snapshot.json.uidMap`, which the API no longer has,
while its remaining assertions passed against a page that never loaded.
Windows-specific problems the suite hit once it actually ran:
- Fixture URLs were built as `file://${path}`, which is unresolvable on
Windows because the drive letter parses as the URL host. Added fixtureUrl()
in tests/helpers/firefox.ts, built on pathToFileURL.
- tests/setup.ts shelled out to pgrep/pkill, so cleanup printed "'pgrep' is
not recognized" per test file and killed nothing, leaving geckodriver.exe
running after every run. Windows now uses taskkill /T, plus a command-line
filtered pass for orphans so the developer's own browser is left alone.
- The extension fixture was packed by calling `zip`, absent on Windows.
Replaced with a small in-process ZIP writer; Firefox installs the result.
- Temp-directory teardown intermittently threw ENOTEMPTY, since
rmSync({ force: true }) suppresses ENOENT but does not retry while handles
close. Added removeDir() in tests/helpers/fs.ts.
Drops the vitest `poolOptions` block, removed in Vitest 4; the existing
`fileParallelism: false` already pins maxWorkers to 1.
CI now runs the full suite on windows-latest alongside ubuntu-latest, so
these regressions are caught rather than rediscovered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records what now works on Windows and the constraints contributors need: Firefox discovery for per-user installs, fixtureUrl() for fixture paths, taskkill-based cleanup, removeDir() for temp directories, and the .gitattributes line-ending rule. The CI notes claimed the workflow "detects Windows and automatically uses this runner" — there was no Windows job at all until this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The saveTo boundary check compared the resolved path against its allowed root with a case-sensitive startsWith. Windows paths are case-insensitive, so `c:\Users\me\.firefox-devtools-mcp\out.json` was rejected as "outside the allowed location" while the identical path with a capital drive letter was accepted. Agents produce either spelling. Ignoring case on Windows only removes false rejections: the comparison now matches what the filesystem considers the same directory, so it cannot let through a path that previously escaped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The registry lookup is the last resort in Firefox discovery, reached only when no well-known directory and no PATH entry holds firefox.exe. Spawning it as bare `reg` resolved the executable through PATH, so the fallback was unavailable in exactly the environments most likely to need it: an MCP client can launch the server with a minimal environment, and with PATH empty the lookup failed with ENOENT instead of consulting the registry. Resolve reg.exe under %SystemRoot%\System32 instead, falling back to the bare name if neither SystemRoot nor windir is set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both log paths opened their file without ensuring the directory existed, while the auto-generated path right beside one of them already called mkdirSync. A --output-file whose directory was missing therefore threw ENOENT from deep inside connect(), and a --log-file in the same state silently disabled logging through a stream error. Windows makes this easy to hit, since paths like /tmp/foo.log do not exist there at all, but the gap is not platform-specific. saveOutput already creates parents for the paths it is given; this brings both log paths in line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run test:mozlog` hardcoded /tmp/firefox-mozlog-test.log, which resolves to C:\tmp on Windows and does not exist, so the script died with ENOENT before launching Firefox. Use the platform temp directory, as the sibling scripts already do. The Taskfile's clean task shelled out to `rm -rf`, unavailable on Windows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the saveTo case-sensitivity rule, the reg.exe/PATH dependency, the log directory behaviour, and the two conventions contributors need: compare paths with isWithinRoot(), and set environment variables explicitly in tests rather than relying on a copied process.env staying case-insensitive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
juliandescottes
left a comment
There was a problem hiding this comment.
Thanks for the patch. I haven't fully tested, but it would be great to have more granular PRs. Could you split this ?
|
|
||
| ## [Unreleased] | ||
|
|
||
| ### Added |
There was a problem hiding this comment.
This is too much for a changelog. Can you keep it short and focus on the impact for users?
| * On Windows geckodriver only searches the Program Files directories and | ||
| * HKEY_LOCAL_MACHINE, so it cannot see a per-user install (%LOCALAPPDATA%, | ||
| * registered under HKCU) — what the installer produces without admin rights. | ||
| * Finding the binary here and passing it as moz:firefoxOptions.binary fixes | ||
| * that. Windows-only: geckodriver's own lookup suffices elsewhere. |
There was a problem hiding this comment.
This sounds like something which should be fixed upstream in geckodriver rather than handled in the MCP with a workaround.
There was a problem hiding this comment.
I think this is captured at https://bugzilla.mozilla.org/show_bug.cgi?id=1921933
(Which is a mentored bug, in case you're interested to do a geckodriver contribution :) )
| - **Firefox discovery**: geckodriver only searches `%ProgramFiles%` and | ||
| `HKEY_LOCAL_MACHINE`, so it cannot find a per-user install | ||
| (`%LOCALAPPDATA%\Mozilla Firefox`). `src/firefox/binary.ts` resolves the binary | ||
| and passes it as `moz:firefoxOptions.binary`. | ||
| - **Fixture URLs**: build them with `fixtureUrl()` from `tests/helpers/firefox.ts`. | ||
| Interpolating `file://${path}` yields `file://C:\...`, which never resolves | ||
| because the drive letter is parsed as the host. | ||
| - **Process cleanup**: `tests/setup.ts` uses `taskkill` on Windows; pgrep/pkill | ||
| do not exist there. | ||
| - **Deleting temp dirs**: use `removeDir()` from `tests/helpers/fs.ts`. Plain | ||
| `rmSync({ force: true })` intermittently throws `ENOTEMPTY` while handles close. | ||
| - **Line endings**: `.gitattributes` checks out text files as LF. Without it | ||
| `core.autocrlf=true` makes `npm run format:check` fail on every file. | ||
| - **Path comparisons**: compare against a root with `isWithinRoot()` | ||
| (`src/utils/save-output.ts`). Windows paths are case-insensitive, so a plain | ||
| `startsWith` rejects valid paths that differ only in case. | ||
| - **`process.env` in tests**: the real environment is case-insensitive on Windows | ||
| (`process.env.SystemRoot` resolves the `SYSTEMROOT` key). Replacing it with a | ||
| plain object drops that, so set the variables a test needs explicitly. |
There was a problem hiding this comment.
This mostly repeats what the commit has been fixing. The documentation files are quite big already, I would skip that.
| - vitest used to hang when forking integration tests that spawn Firefox on Windows (issue #33), so those tests were excluded there and a standalone runner (`scripts/run-integration-tests-windows.mjs`) stood in for them. The hang no longer reproduces on vitest 4, so the exclusion and the standalone runner were both removed and Windows runs the same tests as every other platform. | ||
| - geckodriver only searches the Program Files directories and HKEY_LOCAL_MACHINE for Firefox, so it cannot see a per-user install (`%LOCALAPPDATA%\Mozilla Firefox`), which is what the installer produces without administrator rights. `src/firefox/binary.ts` locates the binary itself and passes it as `moz:firefoxOptions.binary`; use `--firefox-path` if an install still is not found. | ||
| - `.gitattributes` checks out text files with LF everywhere. Without it, Git for Windows' `core.autocrlf=true` gives the working tree CRLF endings and `npm run format:check` fails on every file. |
There was a problem hiding this comment.
Repeats what the commit does, not worth keeping in the docs.
| // mkdirSync rather than shelling out to `mkdir -p`, which does not exist in | ||
| // the cmd.exe that npm runs scripts through on Windows. |
There was a problem hiding this comment.
I would remove this, it only make sense when looking at the diff.
| firefoxOptions.enableBidi(); | ||
|
|
||
| // True when no binary was found on Windows, so the failure can say why. | ||
| let binaryLookupFailed = false; |
There was a problem hiding this comment.
The variable name should make it clear this is windows only
| 'Firefox could not be found on this system. geckodriver only searches the Program Files ' + | ||
| 'directories and HKEY_LOCAL_MACHINE, so a Firefox installed for the current user only is ' + | ||
| 'not detected automatically. Pass the full path to firefox.exe via --firefox-path ' + | ||
| '(for example --firefox-path "%LOCALAPPDATA%\\Mozilla Firefox\\firefox.exe"), or reinstall ' + | ||
| 'Firefox for all users.'; |
There was a problem hiding this comment.
I thought this binary.ts helper was meant to bypass the geckodriver limitation, but the message says that user installed Firefox are not handled?
I would go for a shorter message. eg
Unable to detect Firefox binary automatically, please provide the full path via --firefox-path
Also, why define this constant here if it's only used in core.ts?
| *.bat text eol=crlf | ||
| *.cmd text eol=crlf | ||
| *.ps1 text eol=crlf |
There was a problem hiding this comment.
I don't see any file with those extensions in the repository, is this necessary? Otherwise this would be covered by #164 already?
Summary
Firefox DevTools MCP did not start on Windows when Firefox was installed for the current user only, and the test suite could not validate Windows at all — integration tests were excluded there and CI had no Windows job. This branch fixes the launch failure, gets the full suite running on Windows, and adds
windows-latestto CI so these regressions are caught rather than rediscovered.Verified on Windows 11, Node 22.22.0, Firefox 154.0 (per-user install), geckodriver 0.36.0.
The launch failure
geckodriver searches only the Program Files directories and
HKEY_LOCAL_MACHINE. The Firefox installer run without administrator rights installs to%LOCALAPPDATA%\Mozilla Firefoxand registers underHKCU, so geckodriver cannot see it:src/firefox/binary.tsnow resolves the binary and passes it asmoz:firefoxOptions.binary. Candidates are probed in geckodriver's own order — Program Files variants, then the per-user location — so machines where it already works keep resolving to the same binary and only broken setups change behaviour; thenPATH, then theApp Pathsregistry key. Discovery is Windows-only; geckodriver's own lookup is sufficient elsewhere.When nothing is found, the error now names
--firefox-pathinstead of geckodriver's opaque message, andget_firefox_inforeports the detected path.Integration tests on Windows
They were excluded because vitest hung when forking tests that spawn Firefox (#33), with
scripts/run-integration-tests-windows.mjsstanding in. That hang no longer reproduces on vitest 4 — with the exclusion lifted, all 7 integration files (54 tests) pass.Both the exclusion and the standalone runner are removed rather than kept as a second suite that drifts. It had already drifted: it asserted on
snapshot.json.uidMap, which the API no longer has, and its remaining assertions passed against a page that never loaded, because it built fixture URLs the same broken way described below.Problems the suite hit once it actually ran on Windows:
`file://${path}`, unresolvable on Windows because the drive letter parses as the URL host. AddedfixtureUrl()(tests/helpers/firefox.ts) built onpathToFileURL.pgrep/pkill, sotests/setup.tsprinted'pgrep' is not recognizedonce per test file and killed nothing, leavinggeckodriver.exerunning after every run. Windows now usestaskkill /T, plus a command-line-filtered pass for orphans so the developer's own browser is left alone.zip, absent on Windows. Replaced with a small in-process ZIP writer; Firefox installs the resulting XPI.ENOTEMPTY:rmSync({ force: true })suppressesENOENTbut does not retry while handles close. AddedremoveDir()(tests/helpers/fs.ts).Also drops the vitest
poolOptionsblock, removed in Vitest 4; the existingfileParallelism: falsealready pinsmaxWorkersto 1.Repository tooling
Three failures a Windows contributor hits before writing any code:
core.autocrlf=true(the Git for Windows default) gives the working tree CRLF endings, colliding with Prettier'sendOfLine: "lf". On a clean checkoutnpm run format:checkreported all 59 source files as unformatted, andnpm run formatwould have rewritten every one. A.gitattributesnow pins LF checkout.npm run cleancalledrm -rf, which does not exist in the cmd.exe npm runs scripts through. This also brokeprepublishOnly, sonpm publishcould not run on Windows at all.npm run build:mcpbcalledmkdir -p, same cause.Test plan
npm run format:check,lint,typecheck,typecheck:tests,build— clean on Windowsnpm run test:coveragegeckodriver.exe; the developer's own Firefox processes are left untouched by cleanupnavigate_page,take_snapshot,list_pages,screenshot_page,get_page_text,evaluate_script,get_firefox_infoall succeedReview notes
scripts/run-integration-tests-windows.mjsis the one judgement call here. It is deliberate — the condition it worked around is gone, and it was reporting false passes — but it is easy to restore if you would rather keep it.🤖 Generated with Claude Code
Second pass
A further sweep over path handling, the dev scripts and the environment the server is launched in.
saveTorejected valid Windows pathsThe boundary check in
save-output.tscompared the resolved path against its allowed root with a case-sensitivestartsWith. Windows paths are case-insensitive, so this was refused as "outside the allowed location":Both name the same file, and an agent produces either spelling. Ignoring case on Windows only removes false rejections — the comparison now matches what the filesystem considers the same directory, so nothing that previously escaped can get through.
Registry fallback depended on
PATHFirefox discovery consults the registry only when no well-known directory and no
PATHentry holdsfirefox.exe. It spawned barereg, which is itself resolved throughPATH— so the fallback was unavailable in exactly the environment most likely to need it. An MCP client can launch the server with a minimal environment; withPATHempty the lookup failed withENOENTinstead of reading the registry. Verified:Log paths did not create their directory
Both log paths opened their file without ensuring the directory existed, while the auto-generated path right beside one of them already called
mkdirSync.--output-filethrewENOENTfrom deep insideconnect(), and--log-filesilently disabled logging via a stream error. Not Windows-specific, but easy to hit there since/tmpdoes not exist at all.saveOutputalready creates parents; both log paths now match.Dev scripts
npm run test:mozloghardcoded/tmp/firefox-mozlog-test.log, which resolves toC:\tmpand died withENOENTbefore Firefox started. Now passes on Windows (91 818nsHttplog lines captured).cleantask calledrm -rf.Also verified, no change needed
…\Tomáš Grásl — Můj profiland a fixture namedpříliš-žluťoučký.htmllaunch, navigate, evaluate and snapshot correctly.reg queryoutput on a Czech install. The parser keys off theREG_SZtype tag rather than the value name, which is localised.npm run setupalready resolves the correct%APPDATA%\Claudeconfig paths.Out of scope, reported not fixed
Four exposed dev scripts fail, but not because of Windows — they would fail identically on Linux and macOS:
test:tools,test:input,test:dialogcallfirefox.evaluate('return 1 + 1').evaluate()now passes the expression straight to BiDiscript.evaluate, where a barereturnisSyntaxError: return not in function.test:lifecyclecallsresolveUidToSelector()withoutawait; it isasyncnow, so the expected throw becomes an unhandled rejection.test:dialogadditionally races: it triggers an alert on a 100 ms timer and callsacceptDialog()immediately.scripts/test-closed-window.jsis still POSIX-only (pgrep), but it is not wired to any npm script.These are API drift, not platform issues, so they are left for a separate change rather than widening this PR.
Verification after the second pass
670 tests / 51 files pass on Windows, with
format:check,lint,typecheck,typecheck:testsandbuildall clean.