@samitouri / QOS-React-1 / commits / 6ca51ab1bd

[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');