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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 42 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,27 @@ jobs:
- name: Test
run: pnpm test

- name: Build
run: pnpm build
- name: Build Firefox
run: pnpm build:firefox

# AMO validates the bundle on submission, so catching a manifest it rejects here beats
# finding out during a release. Warnings are informational; only errors fail the step.
- name: Validate Firefox bundle
run: pnpm dlx web-ext@10 lint --source-dir dist-firefox --self-hosted

e2e:
name: E2E
name: E2E (${{ matrix.project }})
# A same-repo PR is already covered by the push event on its branch, so only pushes and fork PRs
# need a run.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
timeout-minutes: 20
runs-on: ubuntu-24.04
needs: build

strategy:
fail-fast: false
matrix:
project: [chrome, firefox]

steps:
- name: Checkout
Expand All @@ -68,8 +81,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
tools: composer
extensions: mbstring, dom, xml, curl, fileinfo, tokenizer, ctype, filter, session, openssl, intl, bcmath, sqlite3, pdo_sqlite
extensions: pdo_sqlite

- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
Expand All @@ -81,31 +93,41 @@ jobs:
with:
node-version: 24
cache: pnpm
# The e2e app is a second pnpm project with its own lockfile, so both belong in the key or
# its install never comes from the store.
cache-dependency-path: |
pnpm-lock.yaml
tests/e2e/app/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium

# Bootstrap the app as explicit steps (not only inside the Playwright webServer
# command) so composer/boot failures surface here with full, untruncated output.
# The app runs through the vite dev server (started by Playwright), so no build here.
- name: Bootstrap e2e app
run: bash tests/e2e/app/setup.sh
# No browser or driver install step: Selenium Manager, bundled with selenium-webdriver, fetches
# both and pairs them in the browser launchers. This only keeps them between runs.
- name: Cache Selenium browsers and drivers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/selenium
key: selenium-${{ runner.os }}-${{ matrix.project }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
selenium-${{ runner.os }}-${{ matrix.project }}-

- name: Install e2e app deps
run: pnpm --dir tests/e2e/app install
run: pnpm --dir tests/e2e/app install --frozen-lockfile

- name: Run e2e tests
run: pnpm exec playwright test -c tests/e2e/playwright.config.ts
run: pnpm test:e2e:${{ matrix.project }}

# Screenshots and the first-retry trace are the only way to see what a failing run
# actually did: the log names these files but the runner throws them away.
- name: Upload Playwright artifacts
# The fixture captures the active Selenium URL, title and screenshot plus window handles and
# browser warnings before teardown. These attachments are the CI failure evidence because
# Playwright does not launch or trace the browser.
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-results
path: test-results/
name: e2e-failures-${{ matrix.project }}
path: tests/e2e/test-results
# Discovery/setup failures can happen before Playwright creates its results directory.
# The test step has already failed, so do not replace its useful error with an upload error.
if-no-files-found: warn
retention-days: 7
15 changes: 10 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,23 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build
- name: Build Chrome
run: pnpm build:chrome

- name: Build Firefox
run: pnpm build:firefox

- name: Package and publish
id: publish
env:
GH_TOKEN: ${{ github.token }}
run: |
version="${GITHUB_REF_NAME#v}"
zip="inertia-devtools-extension-${version}.zip"
(cd dist && zip -qr "../${zip}" .)
gh release create "$GITHUB_REF_NAME" "$zip" --generate-notes
chrome_zip="inertia-devtools-extension-chrome-${version}.zip"
firefox_zip="inertia-devtools-extension-firefox-${version}.zip"
(cd dist-chrome && zip -qr "../${chrome_zip}" .)
(cd dist-firefox && zip -qr "../${firefox_zip}" .)
gh release create "$GITHUB_REF_NAME" "$chrome_zip" "$firefox_zip" --generate-notes

notes="$(gh release view "$GITHUB_REF_NAME" --json body --jq .body)"
{
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
dist
dist-chrome
dist-firefox
test-results
*.zip
inertia-devtools-extension-*/
Expand Down
169 changes: 169 additions & 0 deletions BROWSERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Browsers

Chrome and Firefox ship from the same sources. `manifest.config.ts` generates a manifest per target
and `vite.config.ts` writes each build into its own directory. Neither browser is the default: every
build names its target, so there is no bare `pnpm build`.

```bash
pnpm build:chrome # production build into dist-chrome/
pnpm dev:chrome # watch build into dist-chrome/

pnpm build:firefox # production build into dist-firefox/
pnpm dev:firefox # watch build into dist-firefox/
```

Load the Chrome build through `chrome://extensions` → "Load unpacked" → pick `dist-chrome/`. Load
the Firefox one through `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on" → pick
`dist-firefox/manifest.json`. Temporary add-ons are removed when Firefox closes.

Validate the Firefox bundle the way [addons.mozilla.org](https://addons.mozilla.org/developers/)
(AMO, Mozilla's add-on store) will. CI runs this on every build, and it is worth running by hand
after touching the manifest:

```bash
pnpm dlx web-ext@10 lint --source-dir dist-firefox --self-hosted
```

Two warnings are expected and harmless, so they do not fail the run: `UNSAFE_VAR_ASSIGNMENT` (Vue's
runtime assigns `innerHTML`) and `KEY_FIREFOX_ANDROID_UNSUPPORTED_BY_MIN_VERSION` (Firefox for
Android has no DevTools, so the panel is desktop-only regardless). Anything the validator counts as
an error does fail it.

## Where the targets differ

Chrome is the browser the code was written against, so every entry below is a Firefox constraint the
Chrome build inherits.

- **No service worker.** Firefox has no MV3 `background.service_worker` ([bug 1573659](https://bugzil.la/1573659)),
so its manifest points `background.scripts` at the same `background.js`, which runs as an event
page. It is bundled dependency-free for Chrome already, which is what makes that possible. Event
pages are suspended when idle just like the worker, so nothing may assume in-memory state
survives.
- **No DNR enum objects.** Chrome exposes `declarativeNetRequest.ResourceType`, `RuleActionType` and
`HeaderOperation` as runtime enums; Firefox does not. `src/background/tabRules.ts` uses the wire
strings both accept, because reading a missing enum member would throw inside the `try` and
silently leave the tab header rule uninstalled.
- **Host permissions are revocable.** Firefox prompts for `<all_urls>` at install, but the user can
turn access off per site at any time, which kills content scripts, `webRequest` and the DNR rule in
one go. `src/panel/lib/useHostAccess.ts` detects that and the panel shows a banner instead of
looking like an app without a recorder.
- **The DevTools page is resolved relatively.** Firefox resolves the panel path against the
DevTools page's own URL rather than the extension root, and rejects an extension-absolute URL
outright, so `devtools.html` sits at the root of the build to keep one relative path correct in
both browsers. The panel icon stays empty: Chrome renders the title alone and Firefox falls back to
the manifest icon.
- **The panel's `system` theme means a different thing per browser.** It resolves
`prefers-color-scheme`, and each browser answers that from somewhere else inside a toolbox. Firefox
answers with the DevTools theme, so a light toolbox on a dark desktop renders a light panel, and the
desktop never enters into it. Chrome answers with the OS, so its panel follows the desktop live. Two
settings that look like they should reach the panel do not: Firefox's "Website appearance", which
overrides content documents while a toolbox panel is not one, and the DevTools theme in Chrome. Reading
`devtools.panels.themeName` instead was tried and reverted, since in Firefox the media query already
reports exactly that, and Chrome offers no `onThemeChanged` to keep a reading fresh. The theme toggle
in the panel header overrides all of it.
- **No split incognito.** Chrome gets `"incognito": "split"`, so a private window runs its own worker
and mints its own tab identities and no recording crosses that boundary. Firefox reads `split` as
`not_allowed` ([bug 1380812](https://bugzil.la/1380812)), which would disable the panel in a private
window outright, so its manifest omits the key and keeps the spanning default.
- **`world: "MAIN"` sets the version floor.** Firefox honours it from 128. Its manifest asks for 140
because that is where `data_collection_permissions` is understood. Chrome's floor is 116, where MV3
service workers and DNR session rules landed.

## Automated coverage

One suite covers both browsers. The specs in `tests/e2e/shared` never name a browser: the Playwright
project name picks a driver, so the same test runs twice. Specs that need a browser-only capability
live under `tests/e2e/firefox`, and the project config includes those only in Firefox, so `shared/`
stays honest about running in both browsers.

```bash
pnpm test:e2e # both browser projects
pnpm test:e2e:chrome # Chrome only
pnpm test:e2e:firefox # Firefox only
```

Each test launches a fresh browser session and profile, and its fixture closes that exact runtime in
teardown. Both projects may run in one Playwright process because Selenium and the operating system
own the transport endpoints; the harness has no endpoint allocator.

Playwright is the test runner here, not the browser: it loads extensions into Chromium alone, so both
browsers are driven through `selenium-webdriver` (`tests/e2e/drivers/`). Selenium Manager, which ships
inside that package, downloads and caches both browsers and both drivers as matched pairs, so there is
nothing to install and no version to keep in step. Each browser launcher sets
`SE_FORCE_BROWSER_DOWNLOAD` so Selenium Manager downloads rather than picking up a local install,
and that matters:

- Stable Chrome refuses `--load-extension`. A local install starts fine and silently carries no
extension, so the tests need Chrome for Testing.
- Playwright's bundled Firefox does not inject extension content scripts at all. Entries still arrive,
because those come from `webRequest` in the background, while page state stays empty and every
`visitId` and `batchId` is null. `shared/instrumentation.spec.ts` asserts a `visitId` precisely so
that a browser which drops content scripts fails instead of reporting green.

Background state is read through the messages the panel itself uses (`panel:hydrate` and
`panel:hydrate-page-state`), sent from an extension page. A Chrome service worker and a Firefox event
page answer those identically, so no browser debugging protocol is involved. Both are started lazily,
which is why a session waits for the background to answer before the first navigation:
`webRequest.onHeadersReceived` is what records an entry, and a navigation that beats it awake is
never seen.

An unexpected test result triggers proportional failure capture before teardown. The fixture records
the active URL and title, all window handles, browser warnings, and one active-window screenshot.
Each read is best-effort, so diagnostic failure does not replace the original test failure.

Firefox retains two privileged WebDriver seams. Geckodriver's `--allow-system-access` service switch
allows a temporary extension page to be opened from Firefox's browser context and allows the
Firefox-only `devtools-panel.spec.ts` to open the real toolbox, find and select the registered Inertia
tool, and wait for its panel to render. Page warnings come from WebDriver BiDi.

The rest of the harness is functional: `app.ts`, `extension.ts`, and `panel.ts` expose the small
cross-browser operations used by scenarios, `waits.ts` owns observation helpers, and `fixtures.ts`
creates and tears down one runtime per test.

Chrome still has one explicit manual boundary. Its headless DevTools frontend runs the extension's
DevTools entry page but does not expose custom panels to WebDriver. A unit test therefore locks the
`panels.create` call and tab-specific URL, while the shared suite exercises the built panel directly.
The first manual smoke step remains the proof that Chrome registers the panel in the real toolbox.

## Manual smoke checklist

Run `pnpm build:firefox`, load the add-on, start the e2e app
(`tests/e2e/app`: `php artisan serve --port=13337` plus its own `pnpm dev` on `:4242`) or any Inertia
app in dev mode, then walk through:

1. **Recording.** Open DevTools → Inertia tab, navigate the app. Entries appear with status, method,
component and duration. This alone proves `webRequest.onHeadersReceived` sees the
`x-inertia-devtools-id` header and that the background fetch to
`{origin}/_inertia/devtools/entries/{id}` returns with credentials over plain HTTP.
2. **Tab header.** Reload once (the first response of a newly proven host is unstamped), then check
the app receives `x-inertia-devtools-tab` on later requests. This is the DNR session rule; if the
header never arrives the rule was rejected.
3. **Props and page state.** Props tab shows values with prop-type metadata, Page tab shows the
client page object. Proves the MAIN-world script, the postMessage bridge and pairing.
4. **Lineage and batching.** Trigger a partial reload, a deferred prop and a prefetch-then-visit.
Rows group into batches and the prefetch is marked consumed. Proves the interceptor registry is
reachable from the MAIN world.
5. **Dev-mode banner.** Point at a production build of an app and confirm the "not running in dev
mode" banner appears rather than an empty panel.
6. **Host access banner.** Extensions button → turn off access for the site → the panel shows the
"no access to this site" banner. Turn it back on and it disappears without reopening DevTools.
7. **Suspension.** Leave DevTools open and idle for a few minutes, then navigate again: new entries
still arrive after the event page has been suspended and restarted.

## Store submission

The Release workflow attaches both zips to the GitHub release:
`inertia-devtools-extension-chrome-<version>.zip` goes to the
[Chrome Web Store](https://chrome.google.com/webstore/devconsole) and
`inertia-devtools-extension-firefox-<version>.zip` to AMO. Both are uploaded by hand.

AMO needs two things Chrome does not:

- The bundle is minified, so a source-code archive plus build instructions go with the submission:
Node 24, pnpm 11, `pnpm install --frozen-lockfile && pnpm build:firefox`, output in
`dist-firefox/`.
- `browser_specific_settings.gecko.data_collection_permissions` declares `none`. Keep it accurate:
everything the panel shows is fetched from the inspected app and stays on the machine.

The Gecko extension id and the version floor live in `manifest.config.ts` and must not change once
listed.
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# Inertia.js DevTools for Chrome
# Inertia.js DevTools

Chrome DevTools panel for inspecting Inertia.js requests, page props, route metadata, response bodies, and client-side page state.
Browser DevTools panel for inspecting Inertia.js requests, page props, route metadata, response bodies, and client-side page state. Runs in Chrome and Firefox.

Full documentation, including installation and enabling the server-side recorder, lives at [inertiajs.com/docs/devtools](https://inertiajs.com/docs/devtools).

Both browsers build from the same sources, each into its own directory:

```bash
pnpm build:chrome # dist-chrome/
pnpm build:firefox # dist-firefox/
```

Loading a build, where the two targets differ, and how each one is submitted to its store are
covered in [BROWSERS.md](BROWSERS.md).

## License

Inertia.js DevTools is open-sourced software licensed under the MIT license.
Loading
Loading