diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b043149c9..911563621 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,29 +7,65 @@ on:
   workflow_dispatch:
 
 jobs:
-  build:
+  build-18:
+    name: build (18)
     runs-on: macos-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        node-version: [18, 20]
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
-      - uses: pnpm/action-setup@v4
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
         with:
           version: 9.14.0
 
       - name: Set up Node.js
         uses: actions/setup-node@v4
         with:
-          node-version: ${{ matrix.node-version }}
+          node-version: 18
+          cache: 'pnpm'
+
+      - name: Install dependencies
+        run: pnpm install
+
+      - name: Lint
+        run: pnpm lint
+
+      - name: Build
+        run: pnpm build
+
+      - name: Cache build output
+        uses: actions/cache@v4
+        with:
+          path: ./lib
+          key: ${{ runner.os }}-node-18-build-${{ github.sha }}
+          restore-keys: |
+            ${{ runner.os }}-build-18
+
+  build-20:
+    name: build (20)
+    runs-on: macos-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
+        with:
+          version: 9.14.0
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
           cache: 'pnpm'
 
       - name: Install dependencies
         run: pnpm install
 
+      - name: Lint
+        run: pnpm lint
+
       - name: Build
         run: pnpm build
 
@@ -37,19 +73,20 @@ jobs:
         uses: actions/cache@v4
         with:
           path: ./lib
-          key: ${{ runner.os }}-node-${{ matrix.node-version }}-build-${{ github.sha }}
+          key: ${{ runner.os }}-node-20-build-${{ github.sha }}
           restore-keys: |
-            ${{ runner.os }}-build-${{ matrix.node-version }}
+            ${{ runner.os }}-build-20
 
-  test_unit:
-    name: test (unit)
-    needs: build
+  test-unit-18:
+    name: test (unit) (18)
+    needs: build-18
     runs-on: macos-latest
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
-      - uses: pnpm/action-setup@v4
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
         with:
           version: 9.14.0
 
@@ -73,39 +110,33 @@ jobs:
       - name: Install Playwright browsers
         run: pnpm exec playwright install
 
-      - name: Lint
-        run: pnpm lint
-
       - name: Unit tests
         run: pnpm test:unit
 
-  test-node:
-    name: test (node.js)
-    needs: build
+  test-node-18:
+    name: test (node.js) (18)
+    needs: build-18
     runs-on: macos-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        node-version: [18, 20]
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
-      - uses: pnpm/action-setup@v4
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
         with:
           version: 9.14.0
 
       - name: Set up Node.js
         uses: actions/setup-node@v4
         with:
-          node-version: ${{ matrix.node-version }}
+          node-version: 18
           cache: 'pnpm'
 
       - name: Restore build cache
         uses: actions/cache@v4
         with:
           path: ./lib
-          key: ${{ runner.os }}-node-${{ matrix.node-version }}-build-${{ github.sha }}
+          key: ${{ runner.os }}-node-18-build-${{ github.sha }}
           restore-keys: |
             ${{ runner.os }}-node-18-build-
 
@@ -115,15 +146,82 @@ jobs:
       - name: Node.js tests
         run: pnpm test:node
 
+  test-node-20:
+    name: test (node.js) (20)
+    needs: build-20
+    runs-on: macos-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
+        with:
+          version: 9.14.0
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
+          cache: 'pnpm'
+
+      - name: Restore build cache
+        uses: actions/cache@v4
+        with:
+          path: ./lib
+          key: ${{ runner.os }}-node-20-build-${{ github.sha }}
+          restore-keys: |
+            ${{ runner.os }}-node-20-build-
+
+      - name: Install dependencies
+        run: pnpm install
+
+      - name: Node.js tests
+        run: pnpm test:node
+
+  test-e2e:
+    name: test (e2e) (18)
+    needs: build-18
+    runs-on: macos-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
+        with:
+          version: 9.14.0
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 18
+          cache: 'pnpm'
+
+      - name: Restore build cache
+        uses: actions/cache@v4
+        with:
+          path: ./lib
+          key: ${{ runner.os }}-node-20-build-${{ github.sha }}
+          restore-keys: |
+            ${{ runner.os }}-node-20-build-
+
+      - name: Install dependencies
+        run: pnpm install
+
+      - name: E2E tests
+        run: pnpm test:e2e
+
   test-browser:
     name: test (browser)
-    needs: build
+    needs: build-18
     runs-on: macos-latest
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
-      - uses: pnpm/action-setup@v4
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
         with:
           version: 9.14.0
 
@@ -159,13 +257,14 @@ jobs:
 
   test-native:
     name: test (react-native)
-    needs: build
+    needs: build-18
     runs-on: macos-latest
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
-      - uses: pnpm/action-setup@v4
+      - name: Set up pnpm
+        uses: pnpm/action-setup@v4
         with:
           version: 9.14.0
 
diff --git a/package.json b/package.json
index f3738e0b6..da99bfc27 100644
--- a/package.json
+++ b/package.json
@@ -82,11 +82,12 @@
     "check:exports": "node \"./config/scripts/validate-esm.js\"",
     "test": "pnpm test:unit && pnpm test:node && pnpm test:browser && pnpm test:native",
     "test:unit": "vitest",
-    "test:node": "vitest run --config=./test/node/vitest.config.mts",
+    "test:node": "vitest --config=./test/node/vitest.config.mts",
     "test:native": "vitest --config=./test/native/vitest.config.mts",
     "test:browser": "playwright test -c ./test/browser/playwright.config.ts",
     "test:modules:node": "vitest --config=./test/modules/node/vitest.config.mts",
     "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts",
+    "test:e2e": "vitest run --config=./test/e2e/vitest.config.mts",
     "test:ts": "vitest --typecheck --config=./test/typings/vitest.config.mts",
     "prepare": "pnpm simple-git-hooks init",
     "prepack": "pnpm build",
diff --git a/test/node/msw-api/auto-update-worker.node.test.ts b/test/e2e/auto-update-worker.node.test.ts
similarity index 84%
rename from test/node/msw-api/auto-update-worker.node.test.ts
rename to test/e2e/auto-update-worker.node.test.ts
index f4d58a5d3..69d9fe4be 100644
--- a/test/node/msw-api/auto-update-worker.node.test.ts
+++ b/test/e2e/auto-update-worker.node.test.ts
@@ -1,18 +1,20 @@
-/**
- * @vitest-environment node
- */
-import * as fs from 'fs'
-import { execSync } from 'child_process'
+import fs from 'node:fs'
+import { execSync } from 'node:child_process'
 import { createTeardown } from 'fs-teardown'
-import { fromTemp } from '../../support/utils'
-import * as packageJson from '../../../package.json'
+import { fromTemp } from '../support/utils'
+import * as packageJson from '../../package.json'
 
 const fsMock = createTeardown({
-  rootDir: fromTemp('auto-update-worker'),
+  rootDir: fromTemp('worker-script-auto-update'),
 })
 
-describe.sequential(
+describe(
   'worker script auto-update',
+  {
+    sequential: true,
+    // These tests actually build, pack, and install MSW so they may take time.
+    timeout: 60_000,
+  },
   () => {
     beforeAll(async () => {
       await fsMock.prepare()
@@ -26,7 +28,7 @@ describe.sequential(
       await fsMock.cleanup()
     })
 
-    test('updates the worker script on the postinstall hook', async () => {
+    it('updates the worker script on the "postinstall" hook', async () => {
       await fsMock.create({
         'package.json': JSON.stringify({
           name: 'example',
@@ -53,7 +55,7 @@ describe.sequential(
       ).toEqual(true)
     })
 
-    test('updates multiple directories on the postinstall hook', async () => {
+    it('updates multiple directories on the "postinstall" hook', async () => {
       await fsMock.create({
         'package.json': JSON.stringify({
           name: 'example-multiple-dirs',
@@ -84,8 +86,4 @@ describe.sequential(
       ).toEqual(true)
     })
   },
-  {
-    // These tests actually build, pack, and install MSW so they may take time.
-    timeout: 60_000,
-  },
 )
diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json
new file mode 100644
index 000000000..2afc6e588
--- /dev/null
+++ b/test/e2e/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "include": ["./**/*.test.ts"],
+  "compilerOptions": {
+    "types": ["node", "vitest/globals"],
+    "resolveJsonModule": true,
+    "allowSyntheticDefaultImports": true
+  }
+}
diff --git a/test/e2e/vitest.config.mts b/test/e2e/vitest.config.mts
new file mode 100644
index 000000000..f1c19c102
--- /dev/null
+++ b/test/e2e/vitest.config.mts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vitest/config'
+import { mswExports, customViteEnvironments } from '../support/alias'
+
+export default defineConfig({
+  test: {
+    dir: './test/e2e',
+    globals: true,
+    environment: 'node',
+    poolOptions: {
+      threads: {
+        /**
+         * @note Run Node.js integration tests in sequence.
+         * There's a test that involves building the library,
+         * which results in the "lib" directory being deleted.
+         * If any tests attempt to run during that window,
+         * they will fail, unable to resolve the "msw" import alias.
+         */
+        singleThread: true,
+      },
+    },
+  },
+})
diff --git a/test/node/tsconfig.json b/test/node/tsconfig.json
index d024ec71a..8c9ede84d 100644
--- a/test/node/tsconfig.json
+++ b/test/node/tsconfig.json
@@ -10,7 +10,6 @@
     },
     "noEmit": true,
     "declaration": false,
-    "resolveJsonModule": true,
     // Support default imports for modules that have no default exports.
     // This way "http" imports stay "import http from 'http'".
     // Using wildcard there breaks request interception since it
diff --git a/test/node/vitest.config.mts b/test/node/vitest.config.mts
index 0557df44e..f897cc7ac 100644
--- a/test/node/vitest.config.mts
+++ b/test/node/vitest.config.mts
@@ -14,17 +14,5 @@ export default defineConfig({
         url: 'http://localhost/',
       },
     },
-    poolOptions: {
-      threads: {
-        /**
-         * @note Run Node.js integration tests in sequence.
-         * There's a test that involves building the library,
-         * which results in the "lib" directory being deleted.
-         * If any tests attempt to run during that window,
-         * they will fail, unable to resolve the "msw" import alias.
-         */
-        singleThread: true,
-      },
-    },
   },
 })