[react-devtools-cdt-mcp] run E2E tests in CI (#36824)
Adds a GH Actions workflow for the newly introduced e2e test runner for `react-devtools-cdt-mcp`. Example run - https://github.com/react/react/actions/runs/27830544937/job/82366479218?pr=36824.
Ruslan Lesiutin committed
Jul 2, 2026 at 09:30 UTC
6ca51ab1bd0d726eb4beb8a32f7ad162109710ed
2 files changed
+215
-16
.github/workflows/runtime_build_and_test.yml
+94
-12
@@ -737,9 +737,45 @@ jobs:
737
name: react-devtools
738
pattern: react-devtools-*
739
740
+ runtime_playwright_chromium_cache:
741
+ name: Cache Runtime Playwright Chromium
742
+ needs: runtime_node_modules_cache
743
+ runs-on: ubuntu-latest
744
+ outputs:
745
+ playwright_version: ${{ steps.playwright_version.outputs.playwright_version }}
746
+ steps:
747
+ - uses: actions/checkout@v4
748
+ with:
749
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
750
+ - uses: actions/setup-node@v4
751
+ with:
752
+ node-version-file: '.nvmrc'
753
+ - name: Restore cached node_modules
754
+ uses: actions/cache/restore@v4
755
+ id: node_modules
756
+ with:
757
+ path: |
758
+ **/node_modules
759
+ key: runtime-node_modules-v10-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
760
+ # Don't use restore-keys here. Otherwise the cache grows indefinitely.
761
+ - run: yarn install --frozen-lockfile
762
+ if: steps.node_modules.outputs.cache-hit != 'true'
763
+ - name: Check Playwright version
764
+ id: playwright_version
765
+ run: echo "playwright_version=$(npm ls @playwright/test | grep @playwright | sed 's/.*@//' | head -1)" >> "$GITHUB_OUTPUT"
766
+ - name: Cache Playwright Browsers for version ${{ steps.playwright_version.outputs.playwright_version }}
767
+ id: cache_playwright_browsers
768
+ uses: actions/cache@v4
769
+ with:
770
+ path: ~/.cache/ms-playwright
771
+ key: playwright-browsers-v6-${{ runner.arch }}-${{ runner.os }}-${{ steps.playwright_version.outputs.playwright_version }}
772
+ - name: Install Playwright Chromium
773
+ if: steps.cache_playwright_browsers.outputs.cache-hit != 'true'
774
+ run: npx playwright install chromium
775
+
776
run_devtools_e2e_tests:
741
- name: Run DevTools e2e tests
742
- needs: [build_and_lint, runtime_node_modules_cache]
777
+ name: Run React DevTools browser extension e2e tests
778
+ needs: [build_and_lint, runtime_playwright_chromium_cache]
779
runs-on: ubuntu-latest
780
steps:
781
- uses: actions/checkout@v4
@@ -766,18 +802,12 @@ jobs:
802
pattern: _build_*
803
path: build
804
merge-multiple: true
769
- - name: Check Playwright version
770
- id: playwright_version
771
- run: echo "playwright_version=$(npm ls @playwright/test | grep @playwright | sed 's/.*@//' | head -1)" >> "$GITHUB_OUTPUT"
772
- - name: Cache Playwright Browsers for version ${{ steps.playwright_version.outputs.playwright_version }}
773
- id: cache_playwright_browsers
774
- uses: actions/cache@v4
805
+ - name: Restore Playwright Chromium
806
+ uses: actions/cache/restore@v4
807
with:
808
path: ~/.cache/ms-playwright
777
- key: playwright-browsers-v6-${{ runner.arch }}-${{ runner.os }}-${{ steps.playwright_version.outputs.playwright_version }}
778
- - name: Playwright install deps
779
- if: steps.cache_playwright_browsers.outputs.cache-hit != 'true'
780
- run: npx playwright install --with-deps chromium
809
+ key: playwright-browsers-v6-${{ runner.arch }}-${{ runner.os }}-${{ needs.runtime_playwright_chromium_cache.outputs.playwright_version }}
810
+ fail-on-cache-miss: true
811
- run: ./scripts/ci/run_devtools_e2e_tests.js
812
env:
813
RELEASE_CHANNEL: experimental
@@ -788,6 +818,58 @@ jobs:
818
path: tmp/playwright-artifacts
819
if-no-files-found: warn
820
821
+ run_react_devtools_cdt_mcp_e2e_tests:
822
+ name: Run react-devtools-cdt-mcp e2e tests
823
+ needs: [build_and_lint, runtime_playwright_chromium_cache]
824
+ runs-on: ubuntu-latest
825
+ steps:
826
+ - uses: actions/checkout@v4
827
+ with:
828
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
829
+ - uses: actions/setup-node@v4
830
+ with:
831
+ node-version-file: '.nvmrc'
832
+ - name: Restore cached node_modules
833
+ uses: actions/cache/restore@v4
834
+ id: node_modules
835
+ with:
836
+ path: |
837
+ **/node_modules
838
+ key: runtime-node_modules-v10-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
839
+ # Don't use restore-keys here. Otherwise the cache grows indefinitely.
840
+ - name: Ensure clean build directory
841
+ run: rm -rf build
842
+ - run: yarn install --frozen-lockfile
843
+ if: steps.node_modules.outputs.cache-hit != 'true'
844
+ - name: Restore archived build
845
+ uses: actions/download-artifact@v4
846
+ with:
847
+ pattern: _build_*
848
+ path: build
849
+ merge-multiple: true
850
+ - name: Restore Playwright Chromium
851
+ uses: actions/cache/restore@v4
852
+ with:
853
+ path: ~/.cache/ms-playwright
854
+ key: playwright-browsers-v6-${{ runner.arch }}-${{ runner.os }}-${{ needs.runtime_playwright_chromium_cache.outputs.playwright_version }}
855
+ fail-on-cache-miss: true
856
+ - name: Check Playwright Chromium version
857
+ id: playwright_chromium
858
+ run: |
859
+ echo "executable_path=$(node -e 'process.stdout.write(require("playwright").chromium.executablePath())')" >> "$GITHUB_OUTPUT"
860
+ "$(node -e 'process.stdout.write(require("playwright").chromium.executablePath())')" --version
861
+ - name: Run React DevTools CDT MCP e2e tests
862
+ run: yarn --cwd packages/react-devtools-cdt-mcp test:e2e:ci
863
+ env:
864
+ CHROME_EXECUTABLE_PATH: ${{ steps.playwright_chromium.outputs.executable_path }}
865
+ - name: Archive React DevTools CDT MCP e2e logs
866
+ if: failure()
867
+ uses: actions/upload-artifact@v4
868
+ with:
869
+ name: react-devtools-cdt-mcp-e2e-logs
870
+ path: tmp/react-devtools-cdt-mcp-e2e
871
+ if-no-files-found: warn
872
+
873
# ----- SIZEBOT -----
874
sizebot:
875
if: ${{ github.event_name == 'pull_request' && github.ref_name != 'main' && github.event.pull_request.base.ref == 'main' }}
packages/react-devtools-cdt-mcp/e2e/run.flow.js
+121
-4
@@ -14,6 +14,7 @@ const childProcess = require('child_process');
14
const fs = require('fs');
15
const http = require('http');
16
const net = require('net');
17
+const os = require('os');
18
const path = require('path');
19
20
// eslint-disable-next-line no-undef
@@ -220,11 +221,69 @@ function spawnLogged(
221
stdio: ['ignore', 'pipe', 'pipe'],
222
});
223
appendLog(options.logFile, formatCommand(command, args));
224
+ child.on('error', error => {
225
+ appendLog(
226
+ options.logFile,
227
+ `Failed to spawn ${command}: ${
228
+ error.stack || error.message || String(error)
229
+ }\n`
230
+ );
231
+ });
232
child.stdout.on('data', chunk => appendLog(options.logFile, chunk));
233
child.stderr.on('data', chunk => appendLog(options.logFile, chunk));
234
return child;
235
}
236
237
+function waitForExit(child: ChildProcess, timeout: number): Promise<void> {
238
+ return new Promise(resolve => {
239
+ let done = false;
240
+ const finish = () => {
241
+ if (done) {
242
+ return;
243
+ }
244
+ done = true;
245
+ clearTimeout(timer);
246
+ resolve();
247
+ };
248
+ const timer = setTimeout(() => {
249
+ if (child.exitCode == null) {
250
+ child.kill('SIGKILL');
251
+ }
252
+ finish();
253
+ }, timeout);
254
+
255
+ if (child.exitCode != null) {
256
+ finish();
257
+ return;
258
+ }
259
+ child.once('exit', finish);
260
+ child.once('error', finish);
261
+ });
262
+}
263
+
264
+async function removePathWithRetries(
265
+ targetPath: string,
266
+ logFile: string
267
+): Promise<void> {
268
+ for (let attempt = 0; attempt < 5; attempt++) {
269
+ try {
270
+ fs.rmSync(targetPath, {force: true, recursive: true});
271
+ return;
272
+ } catch (error) {
273
+ if (attempt === 4) {
274
+ appendLog(
275
+ logFile,
276
+ `Failed to remove ${targetPath}: ${
277
+ error.stack || error.message || String(error)
278
+ }\n`
279
+ );
280
+ return;
281
+ }
282
+ await sleep(250);
283
+ }
284
+ }
285
+}
286
+
287
function runCommand(
288
command: string,
289
args: Array<string>,
@@ -275,6 +334,40 @@ function runCommand(
334
});
335
}
336
337
+function startDebuggableChrome(
338
+ chromeExecutablePath: string,
339
+ remoteDebuggingPort: number,
340
+ logFile: string
341
+): {profileDir: string, process: ChildProcess} {
342
+ const profileDir = fs.mkdtempSync(
343
+ path.join(os.tmpdir(), 'react-devtools-cdt-mcp-chrome-')
344
+ );
345
+ appendLog(logFile, `chromeUserDataDir=${profileDir}\n`);
346
+
347
+ const args = [
348
+ '--headless=new',
349
+ `--remote-debugging-port=${remoteDebuggingPort}`,
350
+ '--remote-debugging-address=127.0.0.1',
351
+ `--user-data-dir=${profileDir}`,
352
+ '--no-first-run',
353
+ '--no-default-browser-check',
354
+ 'about:blank',
355
+ ];
356
+
357
+ if (process.platform === 'linux') {
358
+ args.splice(1, 0, '--no-sandbox', '--disable-setuid-sandbox');
359
+ }
360
+
361
+ return {
362
+ profileDir,
363
+ process: spawnLogged(chromeExecutablePath, args, {
364
+ cwd: PACKAGE_DIR,
365
+ env: process.env,
366
+ logFile,
367
+ }),
368
+ };
369
+}
370
+
371
function getChromeDevToolsBin(): string {
372
let packageJsonPath: string;
373
try {
@@ -1078,6 +1171,8 @@ async function main(): Promise<void> {
1171
const appUrl = `http://127.0.0.1:${port}/`;
1172
1173
let fixture: ?ChildProcess;
1174
+ let debuggableChrome: ?ChildProcess;
1175
+ let debuggableChromeProfile: ?string;
1176
const runChrome = (args: Array<string>): Promise<CommandResult> =>
1177
runCommand(
1178
process.execPath,
@@ -1117,14 +1212,25 @@ async function main(): Promise<void> {
1212
const startArgs = [
1213
'start',
1214
'--categoryExperimentalThirdParty=true',
1120
- '--headless=true',
1121
- '--isolated=true',
1215
'--usageStatistics=false',
1216
'--logFile',
1217
chromeLog,
1218
];
1126
- if (process.env.CHROME_EXECUTABLE_PATH) {
1127
- startArgs.push('--executablePath', process.env.CHROME_EXECUTABLE_PATH);
1219
+ const chromeExecutablePath = process.env.CHROME_EXECUTABLE_PATH;
1220
+ if (chromeExecutablePath != null) {
1221
+ const remoteDebuggingPort = await getFreePort();
1222
+ const launchedChrome = startDebuggableChrome(
1223
+ chromeExecutablePath,
1224
+ remoteDebuggingPort,
1225
+ chromeLog
1226
+ );
1227
+ debuggableChrome = launchedChrome.process;
1228
+ debuggableChromeProfile = launchedChrome.profileDir;
1229
+ const browserUrl = `http://127.0.0.1:${remoteDebuggingPort}`;
1230
+ await waitForHttp(`${browserUrl}/json/version`, 30000);
1231
+ startArgs.push('--browserUrl', browserUrl);
1232
+ } else {
1233
+ startArgs.push('--headless=true', '--isolated=true');
1234
}
1235
log('Starting chrome-devtools daemon...');
1236
await chrome.run(startArgs);
@@ -1137,6 +1243,17 @@ async function main(): Promise<void> {
1243
} catch (error) {
1244
appendLog(cliLog, `Failed to stop chrome-devtools: ${error.stack}\n`);
1245
}
1246
+ if (debuggableChrome) {
1247
+ try {
1248
+ debuggableChrome.kill('SIGTERM');
1249
+ await waitForExit(debuggableChrome, 5000);
1250
+ } catch (error) {
1251
+ appendLog(chromeLog, `Failed to stop Chrome: ${error.stack}\n`);
1252
+ }
1253
+ }
1254
+ if (debuggableChromeProfile) {
1255
+ await removePathWithRetries(debuggableChromeProfile, chromeLog);
1256
+ }
1257
if (fixture && fixture.pid) {
1258
try {
1259
process.kill(-fixture.pid, 'SIGTERM');