From 8a9073146c6541ed374541b9bcf9ebe3c4f70e9a Mon Sep 17 00:00:00 2001 From: Artur Morys - Magiera Date: Mon, 28 Oct 2024 18:28:56 +0100 Subject: [PATCH] feat: support experimental RN debugger interaction (#757) * refactor: use switch statement in setupInteractions * refactor: add wrapper for logging of unsupported feature notices when interaction is not implemented in the underlying bundler wrapper * feat: added 'j' experimental debugger interaction * feat: add initial help message listing available interactions * refactor: changes after CR * fix: typo in console message * test: added tests for setupInteractions * chore: upgrade colorette to v2 * refactor(test): use colorette.createColors for mocking colorette in setupInteractions test * refactor: changes after CR * refactor(test): use default parameters instead of mocks for setupInteractions * fix: colorette import in start commands * chore: rename setupInteractions to incldue test in name * refactor: no spread * chore: use ctx.log for async logging * refactor: simplify output * chore: add changeset --------- Co-authored-by: Jakub Romanczyk --- .changeset/nervous-laws-try.md | 5 + packages/repack/package.json | 2 +- .../__tests__/setupInteractions.test.ts | 226 ++++++++++++++++++ .../src/commands/common/setupInteractions.ts | 80 ++++++- packages/repack/src/commands/rspack/start.ts | 18 +- packages/repack/src/commands/webpack/start.ts | 19 +- .../src/logging/reporters/ConsoleReporter.ts | 2 +- pnpm-lock.yaml | 7 +- 8 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 .changeset/nervous-laws-try.md create mode 100644 packages/repack/src/commands/common/__tests__/setupInteractions.test.ts diff --git a/.changeset/nervous-laws-try.md b/.changeset/nervous-laws-try.md new file mode 100644 index 000000000..4608f7b6e --- /dev/null +++ b/.changeset/nervous-laws-try.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": minor +--- + +Display list of available interactions on dev server startup & add support for 'j' to debug diff --git a/packages/repack/package.json b/packages/repack/package.json index c5b40af2f..d124ab13a 100644 --- a/packages/repack/package.json +++ b/packages/repack/package.json @@ -92,7 +92,7 @@ "dependencies": { "@callstack/repack-dev-server": "workspace:*", "@discoveryjs/json-ext": "^0.5.7", - "colorette": "^1.2.2", + "colorette": "^2.0.20", "dedent": "^0.7.0", "events": "^3.3.0", "execa": "^5.0.0", diff --git a/packages/repack/src/commands/common/__tests__/setupInteractions.test.ts b/packages/repack/src/commands/common/__tests__/setupInteractions.test.ts new file mode 100644 index 000000000..a9e2350f7 --- /dev/null +++ b/packages/repack/src/commands/common/__tests__/setupInteractions.test.ts @@ -0,0 +1,226 @@ +import type readline from 'node:readline'; + +import type { Logger } from '../../../types'; +import { setupInteractions } from '../setupInteractions'; + +// eliminate ANSI colors formatting for proper assertions +jest.mock('colorette', () => + jest.requireActual('colorette').createColors({ + useColor: false, + }) +); + +describe('setupInteractions', () => { + let mockLogger: Logger; + let mockProcess: NodeJS.Process; + let mockReadline: typeof readline; + + beforeEach(() => { + mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + + mockProcess = { + stdin: { + setRawMode: jest.fn(), + on: jest.fn(), + }, + stdout: { + write: jest.fn(), + }, + exit: jest.fn(), + emit: jest.fn(), + } as unknown as NodeJS.Process; + + mockReadline = { + emitKeypressEvents: jest.fn(), + } as unknown as typeof readline; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should log a warning if setRawMode is not available', () => { + mockProcess.stdin.setRawMode = undefined as any; + + setupInteractions({}, mockLogger, mockProcess, mockReadline); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Interactive mode is not supported in this environment' + ); + }); + + it('should set up keypress events and interactions', () => { + setupInteractions({}, mockLogger, mockProcess, mockReadline); + + expect(mockReadline.emitKeypressEvents).toHaveBeenCalledWith( + mockProcess.stdin + ); + expect(mockProcess.stdin.setRawMode).toHaveBeenCalledWith(true); + expect(mockProcess.stdin.on).toHaveBeenCalledWith( + 'keypress', + expect.any(Function) + ); + }); + + it('should handle ctrl+c and ctrl+z keypresses', () => { + setupInteractions({}, mockLogger, mockProcess, mockReadline); + + const keypressHandler = (mockProcess.stdin.on as jest.Mock).mock + .calls[0][1]; + + keypressHandler(null, { ctrl: true, name: 'c' }); + expect(mockProcess.exit).toHaveBeenCalled(); + + keypressHandler(null, { ctrl: true, name: 'z' }); + expect(mockProcess.emit).toHaveBeenCalledWith('SIGTSTP', 'SIGTSTP'); + }); + + it('should handle supported interactions', () => { + const handlers: Parameters[0] = { + onReload: jest.fn(), + onOpenDevMenu: jest.fn(), + onOpenDevTools: jest.fn(), + }; + + setupInteractions(handlers, mockLogger, mockProcess, mockReadline); + + const keypressHandler = (mockProcess.stdin.on as jest.Mock).mock + .calls[0][1]; + + keypressHandler(null, { ctrl: false, name: 'r' }); + expect(mockLogger.info).toHaveBeenCalledWith('Reloading app'); + expect(handlers.onReload).toHaveBeenCalledTimes(1); + + keypressHandler(null, { ctrl: false, name: 'd' }); + expect(mockLogger.info).toHaveBeenCalledWith('Opening developer menu'); + expect(handlers.onOpenDevMenu).toHaveBeenCalledTimes(1); + + keypressHandler(null, { ctrl: false, name: 'j' }); + expect(mockLogger.info).toHaveBeenCalledWith('Opening debugger'); + expect(handlers.onOpenDevTools).toHaveBeenCalledTimes(1); + }); + + it('should handle unsupported interactions', () => { + const handlers: Parameters[0] = { + onReload: jest.fn(), + }; + + setupInteractions(handlers, mockLogger, mockProcess, mockReadline); + + expect(mockProcess.stdout.write).toHaveBeenCalledWith(' r: Reload app\n'); + expect(mockProcess.stdout.write).toHaveBeenCalledWith( + ' d: Open developer menu (unsupported by the current bundler)\n' + ); + + const keypressHandler = (mockProcess.stdin.on as jest.Mock).mock + .calls[0][1]; + + keypressHandler(null, { ctrl: false, name: 'd' }); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Open developer menu is not supported by the used bundler' + ); + }); + + it('should properly invoke interaction action callbacks in partial action support scenarios', () => { + const handlers: Parameters[0] = { + onReload: jest.fn(), + onOpenDevTools: jest.fn(), + // onOpenDevMenu - unsupported + }; + + setupInteractions(handlers, mockLogger, mockProcess, mockReadline); + + const keypressHandler = (mockProcess.stdin.on as jest.Mock).mock + .calls[0][1]; + + keypressHandler(null, { ctrl: false, name: 'd' }); + expect(handlers.onReload).toHaveBeenCalledTimes(0); + expect(handlers.onOpenDevTools).toHaveBeenCalledTimes(0); + + keypressHandler(null, { ctrl: false, name: 'r' }); + expect(handlers.onReload).toHaveBeenCalledTimes(1); + expect(handlers.onOpenDevTools).toHaveBeenCalledTimes(0); + + keypressHandler(null, { ctrl: false, name: 'r' }); + expect(handlers.onReload).toHaveBeenCalledTimes(2); + expect(handlers.onOpenDevTools).toHaveBeenCalledTimes(0); + + keypressHandler(null, { ctrl: false, name: 'j' }); + expect(handlers.onReload).toHaveBeenCalledTimes(2); + expect(handlers.onOpenDevTools).toHaveBeenCalledTimes(1); + + keypressHandler(null, { ctrl: false, name: 'j' }); + expect(handlers.onReload).toHaveBeenCalledTimes(2); + expect(handlers.onOpenDevTools).toHaveBeenCalledTimes(2); + }); + + it('should quit on ctrl+c', () => { + const handlers: Parameters[0] = { + onReload: jest.fn(), + onOpenDevTools: jest.fn(), + }; + + setupInteractions(handlers, mockLogger, mockProcess, mockReadline); + + const keypressHandler = (mockProcess.stdin.on as jest.Mock).mock + .calls[0][1]; + + keypressHandler(null, { ctrl: true, name: 'c' }); + expect(mockProcess.exit).toHaveBeenCalledTimes(1); + }); + + it('should quit on ctrl+z', () => { + const handlers: Parameters[0] = { + onReload: jest.fn(), + onOpenDevTools: jest.fn(), + }; + + setupInteractions(handlers, mockLogger, mockProcess, mockReadline); + + const keypressHandler = (mockProcess.stdin.on as jest.Mock).mock + .calls[0][1]; + + keypressHandler(null, { ctrl: true, name: 'z' }); + expect(mockProcess.emit).toHaveBeenCalledTimes(1); + expect(mockProcess.emit).toHaveBeenCalledWith('SIGTSTP', 'SIGTSTP'); + }); + + describe.each([true, false])( + 'should properly display a list of supported interactions (debugger support: %s)', + (debuggerSupport) => { + it('should display interaction messages', () => { + setupInteractions( + { + onOpenDevTools: debuggerSupport ? jest.fn() : undefined, + onOpenDevMenu() {}, + onReload() {}, + }, + mockLogger, + mockProcess, + mockReadline + ); + + expect(mockProcess.stdout.write).toHaveBeenNthCalledWith( + 1, + ' r: Reload app\n' + ); + expect(mockProcess.stdout.write).toHaveBeenNthCalledWith( + 2, + ' d: Open developer menu\n' + ); + expect(mockProcess.stdout.write).toHaveBeenNthCalledWith( + 3, + ` j: Open debugger${debuggerSupport ? '' : ' (unsupported by the current bundler)'}\n` + ); + expect(mockProcess.stdout.write).toHaveBeenNthCalledWith( + 4, + '\nPress Ctrl+c or Ctrl+z to quit the dev server\n\n' + ); + }); + } + ); +}); diff --git a/packages/repack/src/commands/common/setupInteractions.ts b/packages/repack/src/commands/common/setupInteractions.ts index 94a4b36ac..70429c8e1 100644 --- a/packages/repack/src/commands/common/setupInteractions.ts +++ b/packages/repack/src/commands/common/setupInteractions.ts @@ -1,12 +1,30 @@ -import readline from 'node:readline'; +import defaultReadline from 'node:readline'; +import * as colorette from 'colorette'; import type { Logger } from '../../types'; +type Interaction = { + // The function to be executed when this interaction's keystroke is sent. + action?: () => void; + + // The message to be displayed when the action is performed. + postPerformMessage: string; + + // The name of this interaction. + helpName: string; + + // The explanation why this action is not supported at runtime; will be displayed in help listing of interactions if provided. + actionUnsupportedExplanation?: string; +}; + export function setupInteractions( handlers: { onReload?: () => void; onOpenDevMenu?: () => void; + onOpenDevTools?: () => void; }, - logger: Logger = console + logger: Logger = console, + process: NodeJS.Process = global.process, + readline: typeof defaultReadline = defaultReadline ) { if (!process.stdin.setRawMode) { logger.warn('Interactive mode is not supported in this environment'); @@ -23,16 +41,62 @@ export function setupInteractions( case 'c': process.exit(); break; + case 'z': process.emit('SIGTSTP', 'SIGTSTP'); break; } - } else if (name === 'r') { - handlers.onReload?.(); - logger.info('Reloading app'); - } else if (name === 'd') { - handlers.onOpenDevMenu?.(); - logger.info('Opening developer menu'); + } else { + const interaction = plainInteractions[name]; + + if (interaction) { + const { + action, + postPerformMessage, + helpName, + actionUnsupportedExplanation, + } = interaction; + + if (action && actionUnsupportedExplanation === undefined) { + logger.info(postPerformMessage); + + action(); + } else { + logger.warn( + `${helpName} is not supported ${actionUnsupportedExplanation ?? 'by the used bundler'}` + ); + } + } } }); + + const plainInteractions: Record = { + r: { + action: handlers.onReload, + postPerformMessage: 'Reloading app', + helpName: 'Reload app', + }, + d: { + action: handlers.onOpenDevMenu, + postPerformMessage: 'Opening developer menu', + helpName: 'Open developer menu', + }, + j: { + action: handlers.onOpenDevTools, + postPerformMessage: 'Opening debugger', + helpName: 'Open debugger', + }, + }; + + // use process.stdout for sync output at startup + for (const [key, interaction] of Object.entries(plainInteractions)) { + const isSupported = + interaction?.actionUnsupportedExplanation === undefined && + interaction?.action !== undefined; + const text = ` ${colorette.bold(key)}: ${interaction?.helpName}${isSupported ? '' : colorette.yellow(` (unsupported${interaction?.actionUnsupportedExplanation ? `, ${interaction.actionUnsupportedExplanation}` : ' by the current bundler'})`)}\n`; + + process.stdout.write(isSupported ? text : colorette.italic(text)); + } + + process.stdout.write('\nPress Ctrl+c or Ctrl+z to quit the dev server\n\n'); } diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index 9a19acdb6..e969ff9bd 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -1,5 +1,5 @@ import type { Config } from '@react-native-community/cli-types'; -import colorette from 'colorette'; +import * as colorette from 'colorette'; import packageJson from '../../../package.json'; import { ConsoleReporter, @@ -85,12 +85,15 @@ export async function start( // @ts-ignore const compiler = new Compiler(cliOptions, reporter); + const serverHost = args.host || DEFAULT_HOSTNAME; + const serverPort = args.port ?? DEFAULT_PORT; + const serverURL = `${args.https === true ? 'https' : 'http'}://${serverHost}:${serverPort}`; const { createServer } = await import('@callstack/repack-dev-server'); const { start, stop } = await createServer({ options: { rootDir: cliOptions.config.root, - host: args.host || DEFAULT_HOSTNAME, - port: args.port ?? DEFAULT_PORT, + host: serverHost, + port: serverPort, https: args.https ? { cert: args.cert, @@ -106,12 +109,17 @@ export async function start( if (args.interactive) { setupInteractions( { - onReload: () => { + onReload() { ctx.broadcastToMessageClients({ method: 'reload' }); }, - onOpenDevMenu: () => { + onOpenDevMenu() { ctx.broadcastToMessageClients({ method: 'devMenu' }); }, + onOpenDevTools() { + void fetch(`${serverURL}/open-debugger`, { + method: 'POST', + }); + }, }, ctx.log ); diff --git a/packages/repack/src/commands/webpack/start.ts b/packages/repack/src/commands/webpack/start.ts index 5827d3ada..3c5871864 100644 --- a/packages/repack/src/commands/webpack/start.ts +++ b/packages/repack/src/commands/webpack/start.ts @@ -1,6 +1,6 @@ import type { Server } from '@callstack/repack-dev-server'; import type { Config } from '@react-native-community/cli-types'; -import colorette from 'colorette'; +import * as colorette from 'colorette'; import type webpack from 'webpack'; import packageJson from '../../../package.json'; import { @@ -83,12 +83,16 @@ export async function start(_: string[], config: Config, args: StartArguments) { const compiler = new Compiler(cliOptions, reporter, isVerbose); + const serverHost = args.host || DEFAULT_HOSTNAME; + const serverPort = args.port ?? DEFAULT_PORT; + const serverURL = `${args.https === true ? 'https' : 'http'}://${serverHost}:${serverPort}`; + const { createServer } = await import('@callstack/repack-dev-server'); const { start, stop } = await createServer({ options: { rootDir: cliOptions.config.root, - host: args.host || DEFAULT_HOSTNAME, - port: args.port ?? DEFAULT_PORT, + host: serverHost, + port: serverPort, https: args.https ? { cert: args.cert, @@ -104,12 +108,17 @@ export async function start(_: string[], config: Config, args: StartArguments) { if (args.interactive) { setupInteractions( { - onReload: () => { + onReload() { ctx.broadcastToMessageClients({ method: 'reload' }); }, - onOpenDevMenu: () => { + onOpenDevMenu() { ctx.broadcastToMessageClients({ method: 'devMenu' }); }, + onOpenDevTools() { + void fetch(`${serverURL}/open-debugger`, { + method: 'POST', + }); + }, }, ctx.log ); diff --git a/packages/repack/src/logging/reporters/ConsoleReporter.ts b/packages/repack/src/logging/reporters/ConsoleReporter.ts index bcd239d8e..153b492c9 100644 --- a/packages/repack/src/logging/reporters/ConsoleReporter.ts +++ b/packages/repack/src/logging/reporters/ConsoleReporter.ts @@ -1,5 +1,5 @@ import util from 'node:util'; -import colorette from 'colorette'; +import * as colorette from 'colorette'; import throttle from 'throttleit'; import type { LogEntry, LogType, Reporter } from '../types'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23ae565de..3584de118 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,8 +342,8 @@ importers: specifier: ^0.5.7 version: 0.5.7 colorette: - specifier: ^1.2.2 - version: 1.4.0 + specifier: ^2.0.20 + version: 2.0.20 dedent: specifier: ^0.7.0 version: 0.7.0 @@ -3243,6 +3243,8 @@ packages: colorette@1.4.0: resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} @@ -10713,6 +10715,7 @@ snapshots: colorette@1.4.0: {} + colorette@2.0.20: {} colorjs.io@0.5.2: {} combined-stream@1.0.8: