diff --git a/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js b/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js new file mode 100644 index 00000000000..965f55415b7 --- /dev/null +++ b/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js @@ -0,0 +1,130 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {JsonPagesListResponse} from '../inspector-proxy/types'; + +import DefaultBrowserLauncher from '../utils/DefaultBrowserLauncher'; +import {fetchJson, requestLocal} from './FetchUtils'; +import {createDeviceMock} from './InspectorDeviceUtils'; +import {withAbortSignalForEachTest} from './ResourceUtils'; +import {withServerForEachTest} from './ServerUtils'; + +// Must be greater than PAGES_POLLING_INTERVAL in `Device.js` +const PAGES_POLLING_DELAY = 2100; + +jest.useFakeTimers(); + +describe('enableStandaloneFuseboxShell experiment', () => { + const BrowserLauncherWithFuseboxShell = { + ...DefaultBrowserLauncher, + unstable_showFuseboxShell: () => { + throw new Error('Not implemented'); + }, + }; + const serverRef = withServerForEachTest({ + logger: undefined, + projectRoot: '', + unstable_browserLauncher: BrowserLauncherWithFuseboxShell, + unstable_experiments: { + enableStandaloneFuseboxShell: true, + }, + }); + const autoCleanup = withAbortSignalForEachTest(); + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('/open-debugger endpoint', () => { + test('launches the shell with a frontend URL and stable window key', async () => { + // Connect a device to use when opening the debugger + const device = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device.getPages.mockImplementation(() => [ + { + app: 'bar-app', + id: 'page1', + title: 'bar-title', + vm: 'bar-vm', + capabilities: { + // Ensure the device target can be found when launching the debugger + nativePageReloads: true, + // Mark as Fusebox + prefersFuseboxFrontend: true, + }, + }, + ]); + jest.advanceTimersByTime(PAGES_POLLING_DELAY); + + const launchDebuggerAppWindowSpy = jest + .spyOn(BrowserLauncherWithFuseboxShell, 'launchDebuggerAppWindow') + .mockResolvedValue(); + const showFuseboxShellSpy = jest + .spyOn(BrowserLauncherWithFuseboxShell, 'unstable_showFuseboxShell') + .mockResolvedValue(); + + try { + // Fetch the target information for the device + const pageListResponse = await fetchJson( + `${serverRef.serverBaseUrl}/json`, + ); + // Select the first target from the page list response + expect(pageListResponse.length).toBeGreaterThanOrEqual(1); + const firstPage = pageListResponse[0]; + + // Build the URL for the debugger + const openUrl = new URL('/open-debugger', serverRef.serverBaseUrl); + openUrl.searchParams.set('launchId', 'launch1'); + openUrl.searchParams.set( + 'device', + firstPage.reactNative.logicalDeviceId, + ); + openUrl.searchParams.set('target', firstPage.id); + // Request to open the debugger for the first device + { + const response = await requestLocal(openUrl.toString(), { + method: 'POST', + }); + // Ensure the request was handled properly + expect(response.statusCode).toBe(200); + } + openUrl.searchParams.set('launchId', 'launch1'); + + // Ensure the debugger was launched using the standalone shell API + expect(showFuseboxShellSpy).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + ); + const firstWindowKey = showFuseboxShellSpy.mock.calls[0][1]; + + showFuseboxShellSpy.mockClear(); + openUrl.searchParams.set('launchId', 'launch2'); + + { + const response = await requestLocal(openUrl.toString(), { + method: 'POST', + }); + // Ensure the request was handled properly + expect(response.statusCode).toBe(200); + } + // Ensure the debugger was launched using the standalone shell API and the same window key + expect(showFuseboxShellSpy).toHaveBeenCalledWith( + expect.any(String), + firstWindowKey, + ); + + expect(launchDebuggerAppWindowSpy).not.toHaveBeenCalled(); + } finally { + device.close(); + } + }); + }); +}); diff --git a/packages/dev-middleware/src/__tests__/getDevToolsFrontendUrl-test.js b/packages/dev-middleware/src/__tests__/getDevToolsFrontendUrl-test.js index 681aa34938e..e9f7e1d0eb1 100644 --- a/packages/dev-middleware/src/__tests__/getDevToolsFrontendUrl-test.js +++ b/packages/dev-middleware/src/__tests__/getDevToolsFrontendUrl-test.js @@ -18,6 +18,7 @@ describe('getDevToolsFrontendUrl', () => { const experiments = { enableNetworkInspector: false, enableOpenDebuggerRedirect: false, + enableStandaloneFuseboxShell: false, }; describe('relative: false, launchId: undefined, telemetryInfo: undefined, (default)', () => { diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index 0a5a6000bc9..f017ccaf1e9 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -140,6 +140,7 @@ function getExperiments(config: ExperimentsConfig): Experiments { return { enableOpenDebuggerRedirect: config.enableOpenDebuggerRedirect ?? false, enableNetworkInspector: config.enableNetworkInspector ?? false, + enableStandaloneFuseboxShell: config.enableStandaloneFuseboxShell ?? false, }; } diff --git a/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js b/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js index 37cc376f5c6..8c7ef113b65 100644 --- a/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js +++ b/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js @@ -136,27 +136,47 @@ export default function openDebuggerMiddleware({ } const useFuseboxEntryPoint = - target.reactNative.capabilities?.prefersFuseboxFrontend; + target.reactNative.capabilities?.prefersFuseboxFrontend ?? false; try { switch (launchType) { - case 'launch': - await browserLauncher.launchDebuggerAppWindow( - getDevToolsFrontendUrl( - experiments, - target.webSocketDebuggerUrl, - serverBaseUrl, - { - launchId: query.launchId, - telemetryInfo: query.telemetryInfo, - appId: target.appId, - useFuseboxEntryPoint, - }, - ), + case 'launch': { + const frontendUrl = getDevToolsFrontendUrl( + experiments, + target.webSocketDebuggerUrl, + serverBaseUrl, + { + launchId: query.launchId, + telemetryInfo: query.telemetryInfo, + appId: target.appId, + useFuseboxEntryPoint, + }, ); + if ( + useFuseboxEntryPoint && + experiments.enableStandaloneFuseboxShell + ) { + const windowKey = [ + serverBaseUrl, + target.webSocketDebuggerUrl, + target.appId, + ].join('-'); + if (!browserLauncher.unstable_showFuseboxShell) { + throw new Error( + 'Fusebox shell is not supported by the current browser launcher', + ); + } + await browserLauncher.unstable_showFuseboxShell( + frontendUrl, + windowKey, + ); + } else { + await browserLauncher.launchDebuggerAppWindow(frontendUrl); + } res.writeHead(200); res.end(); break; + } case 'redirect': res.writeHead(302, { Location: getDevToolsFrontendUrl( @@ -186,7 +206,7 @@ export default function openDebuggerMiddleware({ pageId: target.id, deviceName: target.deviceName, targetDescription: target.description, - prefersFuseboxFrontend: useFuseboxEntryPoint ?? false, + prefersFuseboxFrontend: useFuseboxEntryPoint, }); return; } catch (e) { @@ -200,7 +220,7 @@ export default function openDebuggerMiddleware({ launchType, status: 'error', error: e, - prefersFuseboxFrontend: useFuseboxEntryPoint ?? false, + prefersFuseboxFrontend: useFuseboxEntryPoint, }); return; } diff --git a/packages/dev-middleware/src/types/BrowserLauncher.js b/packages/dev-middleware/src/types/BrowserLauncher.js index 6711700c9c4..8e3b9169e6f 100644 --- a/packages/dev-middleware/src/types/BrowserLauncher.js +++ b/packages/dev-middleware/src/types/BrowserLauncher.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format */ @@ -18,9 +18,29 @@ export interface BrowserLauncher { * optionally returning an object to control the launched browser instance. * The browser used should be capable of running Chrome DevTools. * - * The provided url is based on serverBaseUrl, and therefore reachable from + * The provided URL is based on serverBaseUrl, and therefore reachable from * the host of dev-middleware. Implementations are responsible for rewriting * this as necessary where the server is remote. */ launchDebuggerAppWindow: (url: string) => Promise; + + /** + * Attempt to open a debugger frontend URL in a standalone shell window + * designed specifically for React Native DevTools. The provided windowKey + * should be used to identify an existing window that can be reused instead + * of opening a new one. + * + * Implementations SHOULD treat an existing session with the same windowKey + * (as long as it's still connected and healthy) as equaivalent to a new + * session with the new URL, even if the launch URLs for the two sessions are + * not identical. Implementations SHOULD NOT unnecessarily close and reopen + * the connection when reusing a session. Implementations SHOULD process any + * changed/new parameters in the URL and update the session accordingly (e.g. + * to preserve telemetry data that may have changed). + * + * The provided URL is based on serverBaseUrl, and therefore reachable from + * the host of dev-middleware. Implementations are responsible for rewriting + * this as necessary where the server is remote. + */ + unstable_showFuseboxShell?: (url: string, windowKey: string) => Promise; } diff --git a/packages/dev-middleware/src/types/Experiments.js b/packages/dev-middleware/src/types/Experiments.js index c719ae52b0d..332af8fbaf8 100644 --- a/packages/dev-middleware/src/types/Experiments.js +++ b/packages/dev-middleware/src/types/Experiments.js @@ -22,6 +22,15 @@ export type Experiments = $ReadOnly<{ */ // NOTE: Used by Expo, exposing a tab labelled "Network (Expo, unstable)" enableNetworkInspector: boolean, + + /** + * Launch the Fusebox frontend in a standalone shell instead of a browser. + * When this is enabled, we will use the optional unstable_showFuseboxShell + * method on the framework-provided BrowserLauncher, or throw an error if the + * method is missing. Note that the default BrowserLauncher does *not* + * implement unstable_showFuseboxShell. + */ + enableStandaloneFuseboxShell: boolean, }>; export type ExperimentsConfig = Partial; diff --git a/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js b/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js index c00b754431c..e9e87f966fb 100644 --- a/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js +++ b/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format */ @@ -19,12 +19,12 @@ const open = require('open'); * Default `BrowserLauncher` implementation which opens URLs on the host * machine. */ -const DefaultBrowserLauncher: BrowserLauncher = { +const DefaultBrowserLauncher = { /** * Attempt to open the debugger frontend in a Google Chrome or Microsoft Edge * app window. */ - launchDebuggerAppWindow: async url => { + launchDebuggerAppWindow: async (url: string): Promise => { let chromePath; try {