Skip to content

fix(windows): make the server and test suite work on Windows - #169

Open
freema wants to merge 9 commits into
mainfrom
fix/windows-compat
Open

fix(windows): make the server and test suite work on Windows#169
freema wants to merge 9 commits into
mainfrom
fix/windows-compat

Conversation

@freema

@freema freema commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

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-latest to 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 Firefox and registers under HKCU, so geckodriver cannot see it:

SessionNotCreatedError: Expected browser binary location, but unable to find binary
in default location, no 'moz:firefoxOptions.binary' capability provided, and no
binary flag set on the command line

src/firefox/binary.ts now resolves the binary and passes it as moz: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; then PATH, then the App Paths registry key. Discovery is Windows-only; geckodriver's own lookup is sufficient elsewhere.

When nothing is found, the error now names --firefox-path instead of geckodriver's opaque message, and get_firefox_info reports 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.mjs standing 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:

  • Fixture URLs were built as `file://${path}`, unresolvable on Windows because the drive letter parses as the URL host. Added fixtureUrl() (tests/helpers/firefox.ts) built on pathToFileURL.
  • Process cleanup shelled out to pgrep/pkill, so tests/setup.ts printed 'pgrep' is not recognized once 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 resulting XPI.
  • Temp-directory teardown intermittently threw ENOTEMPTY: rmSync({ force: true }) suppresses ENOENT but does not retry while handles close. Added removeDir() (tests/helpers/fs.ts).

Also drops the vitest poolOptions block, removed in Vitest 4; the existing fileParallelism: false already pins maxWorkers to 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'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. A .gitattributes now pins LF checkout.
  • npm run clean called rm -rf, which does not exist in the cmd.exe npm runs scripts through. This also broke prepublishOnly, so npm publish could not run on Windows at all.
  • npm run build:mcpb called mkdir -p, same cause.

Test plan

  • npm run format:check, lint, typecheck, typecheck:tests, build — clean on Windows
  • 664 tests / 51 files pass on Windows, unit and integration, across two consecutive runs plus npm run test:coverage
  • No leaked geckodriver.exe; the developer's own Firefox processes are left untouched by cleanup
  • Driven end-to-end through a real MCP stdio client: navigate_page, take_snapshot, list_pages, screenshot_page, get_page_text, evaluate_script, get_firefox_info all succeed

Review notes

  • Removing scripts/run-integration-tests-windows.mjs is 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.
  • Discovery ordering favours Program Files so no currently-working machine changes which binary it launches. Only setups that fail today are affected.

🤖 Generated with Claude Code


Second pass

A further sweep over path handling, the dev scripts and the environment the server is launched in.

saveTo rejected valid Windows paths

The boundary check in save-output.ts compared the resolved path against its allowed root with a case-sensitive startsWith. Windows paths are case-insensitive, so this was refused as "outside the allowed location":

c:\Users\me\.firefox-devtools-mcp\out.json    REJECT
C:\Users\me\.firefox-devtools-mcp\out.json    ALLOW

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 PATH

Firefox discovery consults the registry only when no well-known directory and no PATH entry holds firefox.exe. It spawned bare reg, which is itself resolved through PATH — 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; with PATH empty the lookup failed with ENOENT instead of reading the registry. Verified:

bare 'reg' with empty PATH   : FAILED (ENOENT)
absolute %SystemRoot% path   : OK

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-file threw ENOENT from deep inside connect(), and --log-file silently disabled logging via a stream error. Not Windows-specific, but easy to hit there since /tmp does not exist at all. saveOutput already creates parents; both log paths now match.

Dev scripts

  • npm run test:mozlog hardcoded /tmp/firefox-mozlog-test.log, which resolves to C:\tmp and died with ENOENT before Firefox started. Now passes on Windows (91 818 nsHttp log lines captured).
  • The Taskfile clean task called rm -rf.

Also verified, no change needed

  • Paths with spaces and diacritics: a profile at …\Tomáš Grásl — Můj profil and a fixture named příliš-žluťoučký.html launch, navigate, evaluate and snapshot correctly.
  • Registry parsing on a localised Windows: validated against real reg query output on a Czech install. The parser keys off the REG_SZ type tag rather than the value name, which is localised.
  • npm run setup already resolves the correct %APPDATA%\Claude config 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:dialog call firefox.evaluate('return 1 + 1'). evaluate() now passes the expression straight to BiDi script.evaluate, where a bare return is SyntaxError: return not in function.
  • test:lifecycle calls resolveUidToSelector() without await; it is async now, so the expected throw becomes an unhandled rejection.
  • test:dialog additionally races: it triggers an alert on a 100 ms timer and calls acceptDialog() immediately.

scripts/test-closed-window.js is 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:tests and build all clean.

freema and others added 9 commits August 23, 2026 21:24
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>
@f3tchcodes

Copy link
Copy Markdown
Contributor

FYI, there's some overlap with #165 which already covers Windows process cleanup in tests/setup.ts, and #164 covers the .gitattributes LF fix. Mentioning in case it's useful to avoid duplicate work while reviewing this PR.

@juliandescottes juliandescottes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the patch. I haven't fully tested, but it would be great to have more granular PRs. Could you split this ?

Comment thread CHANGELOG.md

## [Unreleased]

### Added

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too much for a changelog. Can you keep it short and focus on the impact for users?

Comment thread src/firefox/binary.ts
Comment on lines +4 to +8
* 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like something which should be fixed upstream in geckodriver rather than handled in the MCP with a workaround.

@juliandescottes juliandescottes Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 :) )

Comment thread docs/testing.md
Comment on lines +47 to +65
- **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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly repeats what the commit has been fixing. The documentation files are quite big already, I would skip that.

Comment thread docs/ci-and-release.md
Comment on lines +42 to +44
- 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeats what the commit does, not worth keeping in the docs.

Comment thread scripts/build-mcpb.mjs
Comment on lines +38 to +39
// mkdirSync rather than shelling out to `mkdir -p`, which does not exist in
// the cmd.exe that npm runs scripts through on Windows.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this, it only make sense when looking at the diff.

Comment thread src/firefox/core.ts
firefoxOptions.enableBidi();

// True when no binary was found on Windows, so the failure can say why.
let binaryLookupFailed = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name should make it clear this is windows only

Comment thread src/firefox/binary.ts
Comment on lines +129 to +133
'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.';

@juliandescottes juliandescottes Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread .gitattributes
Comment on lines +7 to +9
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any file with those extensions in the repository, is this necessary? Otherwise this would be covered by #164 already?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants