diff --git a/.nx/version-plans/version-plan-1769158802692.md b/.nx/version-plans/version-plan-1769158802692.md new file mode 100644 index 0000000..90fa72c --- /dev/null +++ b/.nx/version-plans/version-plan-1769158802692.md @@ -0,0 +1,5 @@ +--- +__default__: prerelease +--- + +Added `forwardClientLogs` option to forward React Native logs to terminal during tests diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index f4e51dd..c61a4c3 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -3995,7 +3995,7 @@ ZodNaN.create = (params) => { ...processCreateParams(params) }); }; -var BRAND = Symbol("zod_brand"); +var BRAND = /* @__PURE__ */ Symbol("zod_brand"); var ZodBranded = class extends ZodType { _parse(input) { const { ctx } = this._processInputParams(input); @@ -4231,6 +4231,7 @@ var ConfigSchema = external_exports.object({ coverage: external_exports.object({ root: external_exports.string().optional().describe(`Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.`) }).optional(), + forwardClientLogs: external_exports.boolean().optional().default(false).describe("Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error)."), // Deprecated property - used for migration detection include: external_exports.array(external_exports.string()).optional() }).refine((config) => { @@ -4262,7 +4263,6 @@ var et = new RegExp(`(?:\\${Q}(?\\d+)m|\\${U}(?.*)${j})`, "y"); var At = ["up", "down", "left", "right", "space", "enter", "cancel"]; var _ = { actions: new Set(At), aliases: /* @__PURE__ */ new Map([["k", "up"], ["j", "down"], ["h", "left"], ["l", "right"], ["", "cancel"], ["escape", "cancel"]]), messages: { cancel: "Canceled", error: "Something went wrong" }, withGuide: true }; var bt = globalThis.process.platform.startsWith("win"); -var z = Symbol("clack:cancel"); // ../../node_modules/@clack/prompts/dist/index.mjs var import_picocolors = __toESM(require_picocolors(), 1); @@ -4289,7 +4289,7 @@ var Y = w("\u25CF", ">"); var K = w("\u25CB", " "); var te = w("\u25FB", "[\u2022]"); var k2 = w("\u25FC", "[+]"); -var z2 = w("\u25FB", "[ ]"); +var z = w("\u25FB", "[ ]"); var Pe = w("\u25AA", "\u2022"); var se = w("\u2500", "-"); var he = w("\u256E", "+"); diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 67ac165..9f04eba 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -24,14 +24,34 @@ export default { runners: [ androidPlatform({ - name: 'pixel_8_api_33', - device: androidEmulator('Pixel_8_API_33'), - bundleId: 'com.example', + name: 'android', + device: androidEmulator('Pixel_8_API_35', { + apiLevel: 35, + profile: 'pixel_6', + diskSize: '1G', + heapSize: '1G', + }), + bundleId: 'com.harnessplayground', + }), + androidPlatform({ + name: 'moto-g72', + device: physicalAndroidDevice('Motorola', 'Moto G72'), + bundleId: 'com.harnessplayground', + }), + applePlatform({ + name: 'iphone-16-pro', + device: applePhysicalDevice('iPhone (Szymon) (2)'), + bundleId: 'react-native-harness', }), applePlatform({ - name: 'iphone-16-pro-max', - device: appleSimulator('iPhone 16 Pro Max', '26.0'), - bundleId: 'com.example', + name: 'ios', + device: appleSimulator('iPhone 16 Pro', '18.6'), + bundleId: 'com.harnessplayground', + }), + vegaPlatform({ + name: 'vega', + device: vegaEmulator('VegaTV_1'), + bundleId: 'com.playground', }), webPlatform({ name: 'web', @@ -42,5 +62,11 @@ export default { browser: chromium('http://localhost:8081/index.html', { headless: true }), }), ], - defaultRunner: 'pixel_8_api_33', + defaultRunner: 'android', + bridgeTimeout: 120000, + webSocketPort: 3002, + + resetEnvironmentBetweenTestFiles: true, + unstable__skipAlreadyIncludedModules: false, + forwardClientLogs: true, }; diff --git a/packages/bundler-metro/src/reporter.ts b/packages/bundler-metro/src/reporter.ts index 1e7f5f9..ea72c58 100644 --- a/packages/bundler-metro/src/reporter.ts +++ b/packages/bundler-metro/src/reporter.ts @@ -7,6 +7,11 @@ export type ReportableEvent = | MetroReportableEvent | { type: 'initialize_done'; + } + | { + type: 'client_log'; + level: 'trace' | 'info' | 'warn' | 'log' | 'group' | 'groupCollapsed' | 'groupEnd' | 'debug' | 'error'; + data: unknown[]; }; export type Reporter = EventEmitter; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 87f289f..916d9ab 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -60,6 +60,15 @@ export const ConfigSchema = z }) .optional(), + forwardClientLogs: z + .boolean() + .optional() + .default(false) + .describe( + 'Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. ' + + 'When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' + ), + // Deprecated property - used for migration detection include: z.array(z.string()).optional(), }) diff --git a/packages/jest/eslint.config.mjs b/packages/jest/eslint.config.mjs index c593748..bf913da 100644 --- a/packages/jest/eslint.config.mjs +++ b/packages/jest/eslint.config.mjs @@ -8,9 +8,12 @@ export default [ '@nx/dependency-checks': [ 'error', { - ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/vite.config.{js,cjs,mjs,ts,cts,mts}', + ], // jest-runner: we only ingest types - ignoredDependencies: ['@react-native-harness/cli', 'jest-runner'], + ignoredDependencies: ['@react-native-harness/cli', 'jest-runner', 'vitest'], }, ], }, diff --git a/packages/jest/src/__tests__/client-log-handler.test.ts b/packages/jest/src/__tests__/client-log-handler.test.ts new file mode 100644 index 0000000..2f94f43 --- /dev/null +++ b/packages/jest/src/__tests__/client-log-handler.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + formatClientLogMessage, + formatClientLogLine, + handleClientLogEvent, + createClientLogListener, + type ClientLogEvent, +} from '../client-log-handler.js'; + +// Mock the log function +vi.mock('../logs.js', () => ({ + log: vi.fn(), +})); + +import { log } from '../logs.js'; + +describe('client-log-handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('formatClientLogMessage', () => { + it('should format a single string', () => { + const result = formatClientLogMessage(['Hello, world!']); + expect(result).toBe('Hello, world!'); + }); + + it('should join multiple strings with spaces', () => { + const result = formatClientLogMessage(['Hello', 'world', '!']); + expect(result).toBe('Hello world !'); + }); + + it('should format objects', () => { + const result = formatClientLogMessage([{ key: 'value' }]); + // util.format uses inspect-style output for objects + expect(result).toContain('key'); + expect(result).toContain('value'); + }); + + it('should format arrays', () => { + const result = formatClientLogMessage([[1, 2, 3]]); + expect(result).toContain('1'); + expect(result).toContain('2'); + expect(result).toContain('3'); + }); + + it('should handle mixed types', () => { + const result = formatClientLogMessage([ + 'Message:', + { count: 42 }, + 'items', + ]); + expect(result).toContain('Message:'); + expect(result).toContain('count'); + expect(result).toContain('42'); + expect(result).toContain('items'); + }); + + it('should format numbers', () => { + const result = formatClientLogMessage([123, 456]); + expect(result).toBe('123 456'); + }); + + it('should format booleans', () => { + const result = formatClientLogMessage([true, false]); + expect(result).toBe('true false'); + }); + + it('should format null and undefined', () => { + const result = formatClientLogMessage([null, undefined]); + expect(result).toBe('null undefined'); + }); + + it('should handle empty array', () => { + const result = formatClientLogMessage([]); + expect(result).toBe(''); + }); + + describe('printf-style format specifiers', () => { + it('should handle %s string substitution', () => { + const result = formatClientLogMessage(['%s world', 'hello']); + expect(result).toBe('hello world'); + }); + + it('should handle %d integer substitution', () => { + const result = formatClientLogMessage(['Count: %d', 42]); + expect(result).toBe('Count: 42'); + }); + + it('should handle %i integer substitution', () => { + const result = formatClientLogMessage(['Value: %i', 123]); + expect(result).toBe('Value: 123'); + }); + + it('should handle %f float substitution', () => { + const result = formatClientLogMessage(['Pi: %f', 3.14159]); + expect(result).toContain('3.14159'); + }); + + it('should handle multiple substitutions', () => { + const result = formatClientLogMessage(['Hello %s, you have %d messages', 'Alice', 5]); + expect(result).toBe('Hello Alice, you have 5 messages'); + }); + + it('should handle %j JSON substitution', () => { + const result = formatClientLogMessage(['Data: %j', { key: 'value' }]); + expect(result).toBe('Data: {"key":"value"}'); + }); + + it('should handle %o object substitution', () => { + const result = formatClientLogMessage(['Object: %o', { a: 1 }]); + // %o produces inspect-style output, just check it contains the key + expect(result).toContain('a'); + }); + + it('should handle %% as literal percent when substituting', () => { + // %% is only converted to % when there are substitutions + const result = formatClientLogMessage(['%s is 100%% complete', 'Task']); + expect(result).toBe('Task is 100% complete'); + }); + + it('should append extra arguments after substitution', () => { + const result = formatClientLogMessage(['Hello %s', 'world', 'extra', 'args']); + expect(result).toBe('Hello world extra args'); + }); + }); + }); + + describe('formatClientLogLine', () => { + it('should format log level event', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'log', + data: ['Test message'], + }; + const result = formatClientLogLine(event); + // The result will contain ANSI codes for styling, so we check it contains the message + expect(result).toContain('Test message'); + }); + + it('should format error level event', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'error', + data: ['Error occurred'], + }; + const result = formatClientLogLine(event); + expect(result).toContain('Error occurred'); + }); + + it('should format warn level event', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'warn', + data: ['Warning message'], + }; + const result = formatClientLogLine(event); + expect(result).toContain('Warning message'); + }); + + it('should format info level event', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'info', + data: ['Info message'], + }; + const result = formatClientLogLine(event); + expect(result).toContain('Info message'); + }); + + it('should format debug level event', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'debug', + data: ['Debug message'], + }; + const result = formatClientLogLine(event); + expect(result).toContain('Debug message'); + }); + + it('should format trace level event', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'trace', + data: ['Trace message'], + }; + const result = formatClientLogLine(event); + expect(result).toContain('Trace message'); + }); + + it('should handle multiple data items', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'log', + data: ['User:', { id: 1, name: 'Test' }], + }; + const result = formatClientLogLine(event); + expect(result).toContain('User:'); + expect(result).toContain('id'); + expect(result).toContain('Test'); + }); + }); + + describe('handleClientLogEvent', () => { + it('should handle client_log events and call log', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'log', + data: ['Test message'], + }; + + const result = handleClientLogEvent(event); + + expect(result).toBe(true); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Test message')); + }); + + it('should return false for non-client_log events', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const event = { type: 'bundle_build_started' } as any; + + const result = handleClientLogEvent(event); + + expect(result).toBe(false); + expect(log).not.toHaveBeenCalled(); + }); + + it('should handle error level logs', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'error', + data: ['Something went wrong'], + }; + + handleClientLogEvent(event); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Something went wrong') + ); + }); + + it('should handle warn level logs', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'warn', + data: ['Deprecation warning'], + }; + + handleClientLogEvent(event); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Deprecation warning') + ); + }); + }); + + describe('createClientLogListener', () => { + it('should create a listener function', () => { + const listener = createClientLogListener(); + expect(typeof listener).toBe('function'); + }); + + it('should handle client_log events when called', () => { + const listener = createClientLogListener(); + const event: ClientLogEvent = { + type: 'client_log', + level: 'info', + data: ['Listener test'], + }; + + listener(event); + + expect(log).toHaveBeenCalledWith(expect.stringContaining('Listener test')); + }); + + it('should ignore non-client_log events', () => { + const listener = createClientLogListener(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const event = { type: 'initialize_done' } as any; + + listener(event); + + expect(log).not.toHaveBeenCalled(); + }); + }); + + describe('group events', () => { + it('should handle group events without logging', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'group', + data: ['Group label'], + }; + + const result = handleClientLogEvent(event); + + expect(result).toBe(true); + expect(log).not.toHaveBeenCalled(); + }); + + it('should handle groupCollapsed events without logging', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'groupCollapsed', + data: ['Collapsed'], + }; + + const result = handleClientLogEvent(event); + + expect(result).toBe(true); + expect(log).not.toHaveBeenCalled(); + }); + + it('should handle groupEnd events without logging', () => { + const event: ClientLogEvent = { + type: 'client_log', + level: 'groupEnd', + data: [], + }; + + const result = handleClientLogEvent(event); + + expect(result).toBe(true); + expect(log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/jest/src/client-log-handler.ts b/packages/jest/src/client-log-handler.ts new file mode 100644 index 0000000..c9dfc3b --- /dev/null +++ b/packages/jest/src/client-log-handler.ts @@ -0,0 +1,101 @@ +import type { ReportableEvent } from '@react-native-harness/bundler-metro'; +import chalk from 'chalk'; +import util from 'node:util'; +import { log } from './logs.js'; + +export type ClientLogEvent = Extract; + +type LogLevel = ClientLogEvent['level']; + +/** + * Gets the display level for a log level. + * Note: Metro treats 'trace' as 'log' because Hermes doesn't include stack traces. + */ +const getDisplayLevel = (level: LogLevel): string => { + // Metro converts trace to log for display (Hermes doesn't provide stack traces) + if (level === 'trace') { + return 'LOG'; + } + return level.toUpperCase(); +}; + +/** + * Creates a styled tag for a log level with colored box appearance. + */ +const createLevelTag = (level: LogLevel): string => { + const displayLevel = getDisplayLevel(level); + const label = ` ${displayLevel} `; + + if (!chalk.supportsColor) { + return `[${displayLevel}]`; + } + + switch (level) { + case 'error': + return chalk.reset.inverse.bold.red(label); + case 'warn': + return chalk.reset.inverse.bold.yellow(label); + case 'info': + return chalk.reset.inverse.bold.cyan(label); + case 'debug': + return chalk.reset.inverse.bold.blue(label); + case 'trace': + // Trace displays as LOG but with a distinct color + return chalk.reset.inverse.bold.magenta(label); + case 'log': + default: + return chalk.reset.inverse.bold.white(label); + } +}; + +/** + * Formats a client log event data array into a string message. + * Uses util.format for printf-style format specifier support (%s, %d, %j, etc.) + */ +export const formatClientLogMessage = (data: unknown[]): string => { + if (data.length === 0) { + return ''; + } + return util.format(...data); +}; + +/** + * Formats a client log event into a log line with styled level prefix. + */ +export const formatClientLogLine = (event: ClientLogEvent): string => { + const tag = createLevelTag(event.level); + const message = formatClientLogMessage(event.data); + return `${tag} ${message}`; +}; + +/** + * Handles a client_log event by formatting and logging it. + * Returns true if the event was handled, false otherwise. + */ +export const handleClientLogEvent = (event: ReportableEvent): boolean => { + if (event.type !== 'client_log') { + return false; + } + + // Skip group events - they don't produce output + if ( + event.level === 'group' || + event.level === 'groupCollapsed' || + event.level === 'groupEnd' + ) { + return true; + } + + const logLine = formatClientLogLine(event); + log(logLine); + return true; +}; + +/** + * Creates a client log event listener that can be added to the Metro reporter. + */ +export const createClientLogListener = (): ((event: ReportableEvent) => void) => { + return (event: ReportableEvent) => { + handleClientLogEvent(event); + }; +}; diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index e1fd62b..cf7d54f 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -19,6 +19,7 @@ import { import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { createCrashMonitor, CrashMonitor } from './crash-monitor.js'; +import { createClientLogListener } from './client-log-handler.js'; export type HarnessRunTestsOptions = Exclude; @@ -144,7 +145,17 @@ const getHarnessInternal = async ( }), ]); + // Forward client logs to console if enabled + const clientLogListener = createClientLogListener(); + + if (config.forwardClientLogs) { + metroInstance.events.addListener(clientLogListener); + } + const dispose = async () => { + if (config.forwardClientLogs) { + metroInstance.events.removeListener(clientLogListener); + } await Promise.all([ serverBridge.dispose(), platformInstance.dispose(), diff --git a/packages/jest/vite.config.ts b/packages/jest/vite.config.ts new file mode 100644 index 0000000..1a03a2e --- /dev/null +++ b/packages/jest/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/jest', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/metro/src/withRnHarness.ts b/packages/metro/src/withRnHarness.ts index d7c68f7..557e5cf 100644 --- a/packages/metro/src/withRnHarness.ts +++ b/packages/metro/src/withRnHarness.ts @@ -32,6 +32,10 @@ export const withRnHarness = ( const patchedConfig: MetroConfig = { ...metroConfig, cacheVersion: 'react-native-harness', + server: { + ...metroConfig.server, + forwardClientLogs: harnessConfig.forwardClientLogs ?? false, + }, serializer: { ...metroConfig.serializer, getPolyfills: (...args) => [ diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 47404e2..8f85437 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -104,8 +104,14 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` // Optional: Reset environment between test files (default: true) resetEnvironmentBetweenTestFiles?: boolean; - // Optional: Root directory for coverage instrumentation in monorepos (default: process.cwd()) - coverageRoot?: string; + // Optional: Coverage configuration + coverage?: { + // Root directory for coverage instrumentation in monorepos (default: process.cwd()) + root?: string; + }; + + // Forward console.log/warn/error/etc from the app to the terminal (default: false) + forwardClientLogs?: boolean; } ``` @@ -385,6 +391,28 @@ Without specifying `coverageRoot`, babel-plugin-istanbul may skip instrumenting Set `coverageRoot` when you notice 0% coverage in your reports or when source files are not being instrumented for coverage. This commonly occurs in create-react-native-library projects and other monorepo setups. ::: +## Log Forwarding + +React Native Harness can forward `console.log`, `console.warn`, `console.error`, and other console method calls from your app to the terminal output. This is useful for debugging tests and understanding what your app is doing during test execution. + +```javascript +{ + forwardClientLogs: true, +} +``` + +**Default:** `false` + +When enabled, you'll see logs in your terminal with styled level indicators: + +``` + LOG Hello from the app! + WARN This is a warning + ERROR Something went wrong + INFO Informational message + DEBUG Debug details +``` + ## Finding Device and Simulator IDs ### Android Emulators