Skip to content

Commit

Permalink
feat: support experimental RN debugger interaction (#757)
Browse files Browse the repository at this point in the history
* 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 <jakub.romanczyk@callstack.com>
  • Loading branch information
artus9033 and jbroma authored Oct 28, 2024
1 parent 176324a commit 8a90731
Show file tree
Hide file tree
Showing 8 changed files with 337 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/nervous-laws-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": minor
---

Display list of available interactions on dev server startup & add support for 'j' to debug
2 changes: 1 addition & 1 deletion packages/repack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof setupInteractions>[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<typeof setupInteractions>[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<typeof setupInteractions>[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<typeof setupInteractions>[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<typeof setupInteractions>[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'
);
});
}
);
});
80 changes: 72 additions & 8 deletions packages/repack/src/commands/common/setupInteractions.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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<string, Interaction | undefined> = {
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');
}
18 changes: 13 additions & 5 deletions packages/repack/src/commands/rspack/start.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
);
Expand Down
Loading

0 comments on commit 8a90731

Please sign in to comment.