Skip to content

Commit 55a1412

Browse files
feat(runtime,jest,bridge): add console forwarding and batched state updates
Console Forwarding: - Forward console.log/warn/error/info/debug from device to Jest output - Add ConsoleEvent/ConsoleLevel types to BridgeEvents union - Preserve Error details (stack/message) in forwarded output - Fix %d printf placeholder to use Number() for decimals Type Improvements: - Remove type assertions in factory.ts with proper ConsoleEvent types - Add proper generic constraints for Harness.on/off methods - Import shared ConsoleEvent type in jest package Shared Utilities: - Extract batchedUpdate utility to avoid code duplication - Add resetRenderState helper for common state reset pattern - Simplify render/cleanup.ts using shared utilities
1 parent 0f0ca1b commit 55a1412

9 files changed

Lines changed: 216 additions & 20 deletions

File tree

packages/bridge/src/shared.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
} from './shared/test-runner.js';
55
import type { TestCollectorEvents } from './shared/test-collector.js';
66
import type { BundlerEvents } from './shared/bundler.js';
7+
import type { ConsoleEvent } from './shared/console.js';
78
import type { HarnessPlatform } from '@react-native-harness/platforms';
89

910
export type FileReference = {
@@ -103,6 +104,7 @@ export type {
103104
SetupFileBundlingFailedEvent,
104105
BundlerEvents,
105106
} from './shared/bundler.js';
107+
export type { ConsoleEvent, ConsoleLevel } from './shared/console.js';
106108

107109
export type DeviceDescriptor = {
108110
platform: 'ios' | 'android' | 'vega';
@@ -114,7 +116,8 @@ export type DeviceDescriptor = {
114116
export type BridgeEvents =
115117
| TestCollectorEvents
116118
| TestRunnerEvents
117-
| BundlerEvents;
119+
| BundlerEvents
120+
| ConsoleEvent;
118121

119122
export type BridgeEventsMap = {
120123
[K in BridgeEvents['type']]: (
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug';
2+
3+
export type ConsoleEvent = {
4+
type: 'console';
5+
level: ConsoleLevel;
6+
args: string[];
7+
timestamp: number;
8+
};

packages/jest/src/harness.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
getBridgeServer,
33
BridgeServer,
4+
BridgeServerEvents,
45
} from '@react-native-harness/bridge/server';
56
import {
67
HarnessContext,
@@ -31,6 +32,14 @@ export type Harness = {
3132
restart: () => Promise<void>;
3233
dispose: () => Promise<void>;
3334
crashMonitor: CrashMonitor;
35+
on: <T extends keyof BridgeServerEvents>(
36+
event: T,
37+
handler: BridgeServerEvents[T]
38+
) => void;
39+
off: <T extends keyof BridgeServerEvents>(
40+
event: T,
41+
handler: BridgeServerEvents[T]
42+
) => void;
3443
};
3544

3645
export const waitForAppReady = async (options: {
@@ -205,6 +214,8 @@ const getHarnessInternal = async (
205214
restart,
206215
dispose,
207216
crashMonitor,
217+
on: (event, handler) => serverBridge.on(event, handler),
218+
off: (event, handler) => serverBridge.off(event, handler),
208219
};
209220
};
210221

packages/jest/src/index.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
TestWatcher,
1010
} from 'jest-runner';
1111
import pLimit from 'p-limit';
12+
import chalk from 'chalk';
1213
import { runHarnessTestFile } from './run.js';
1314
import { Config as HarnessConfig } from '@react-native-harness/config';
1415
import { type Harness } from './harness.js';
@@ -18,6 +19,72 @@ import { HarnessError } from '@react-native-harness/tools';
1819
import { getErrorMessage } from './logs.js';
1920
import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
2021
import { NativeCrashError } from './errors.js';
22+
import { ConsoleEvent } from '@react-native-harness/bridge';
23+
24+
// Printf-style string interpolation for console messages
25+
const formatConsoleMessage = (args: string[]): string => {
26+
if (!args || args.length === 0) return '';
27+
if (args.length === 1) return args[0];
28+
29+
let template = String(args[0]);
30+
let argIndex = 1;
31+
32+
// Replace %s, %d, %i, %o, %O, %j with corresponding arguments
33+
template = template.replace(/%[sdioOj]/g, (match) => {
34+
if (argIndex >= args.length) return match;
35+
const arg = args[argIndex++];
36+
switch (match) {
37+
case '%s':
38+
return String(arg);
39+
case '%d':
40+
return String(Number(arg));
41+
case '%i':
42+
return String(parseInt(String(arg), 10));
43+
case '%o':
44+
case '%O':
45+
case '%j':
46+
return typeof arg === 'string' ? arg : JSON.stringify(arg);
47+
default:
48+
return String(arg);
49+
}
50+
});
51+
52+
// Append remaining arguments
53+
const remaining = args.slice(argIndex);
54+
if (remaining.length > 0) {
55+
template += ' ' + remaining.join(' ');
56+
}
57+
58+
return template;
59+
};
60+
61+
// Console event handler - prints console messages from device
62+
const createConsoleEventHandler = (): ((event: ConsoleEvent) => void) => {
63+
return (event: ConsoleEvent) => {
64+
if (event.type === 'console') {
65+
const message = formatConsoleMessage(event.args);
66+
const tags: Record<string, string> = {
67+
log: chalk.supportsColor
68+
? chalk.reset.inverse.bold.cyan(' LOG ')
69+
: 'LOG',
70+
warn: chalk.supportsColor
71+
? chalk.reset.inverse.bold.yellow(' WARN ')
72+
: 'WARN',
73+
error: chalk.supportsColor
74+
? chalk.reset.inverse.bold.red(' ERROR ')
75+
: 'ERROR',
76+
info: chalk.supportsColor
77+
? chalk.reset.inverse.bold.blue(' INFO ')
78+
: 'INFO',
79+
debug: chalk.supportsColor
80+
? chalk.reset.inverse.bold.gray(' DEBUG ')
81+
: 'DEBUG',
82+
};
83+
const tag = tags[event.level] || tags.log;
84+
process.stderr.write(`${tag} ${message}\n`);
85+
}
86+
};
87+
};
2188

2289
class CancelRun extends Error {
2390
constructor(message?: string) {
@@ -47,13 +114,21 @@ export default class JestHarness implements CallbackTestRunnerInterface {
47114
throw new Error('Parallel test running is not supported');
48115
}
49116

117+
let consoleHandler: ((event: ConsoleEvent) => void) | null = null;
118+
50119
try {
51120
// This is necessary as Harness may throw and we want to catch it and display a helpful error message.
52121
await setup(this.#globalConfig);
53122

54123
const harness = global.HARNESS;
55124
const harnessConfig = global.HARNESS_CONFIG;
56125

126+
// Setup console forwarding if not in silent mode
127+
if (!this.#globalConfig.silent) {
128+
consoleHandler = createConsoleEventHandler();
129+
harness.on('event', consoleHandler);
130+
}
131+
57132
return await this._createInBandTestRun(
58133
tests,
59134
watcher,
@@ -71,6 +146,10 @@ export default class JestHarness implements CallbackTestRunnerInterface {
71146

72147
throw error;
73148
} finally {
149+
// Cleanup console handler
150+
if (consoleHandler && global.HARNESS) {
151+
global.HARNESS.off('event', consoleHandler);
152+
}
74153
// This is necessary as Harness may throw and we want to catch it and display a helpful error message.
75154
await teardown(this.#globalConfig);
76155
}

packages/runtime/src/client/factory.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type {
33
TestCollectorEvents,
44
BundlerEvents,
55
TestExecutionOptions,
6+
ConsoleLevel,
7+
ConsoleEvent,
68
} from '@react-native-harness/bridge';
79
import { getBridgeClient } from '@react-native-harness/bridge/client';
810
import { store } from '../ui/state.js';
@@ -16,6 +18,61 @@ import { setup } from '../render/setup.js';
1618
import { runSetupFiles } from './setup-files.js';
1719
import { setClient } from './store.js';
1820

21+
type EmitEventFn = (type: ConsoleEvent['type'], data: ConsoleEvent) => void;
22+
23+
// Console forwarding setup - intercepts console calls and emits events to host
24+
const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => {
25+
const originalConsole: Record<ConsoleLevel, typeof console.log> = {
26+
log: console.log,
27+
warn: console.warn,
28+
error: console.error,
29+
info: console.info,
30+
debug: console.debug,
31+
};
32+
33+
const createForwarder =
34+
(level: ConsoleLevel) =>
35+
(...args: unknown[]) => {
36+
// Call original console method
37+
originalConsole[level](...args);
38+
39+
// Forward to host via bridge
40+
try {
41+
emitEvent('console', {
42+
type: 'console',
43+
level,
44+
args: args.map((arg) => {
45+
try {
46+
if (arg instanceof Error) {
47+
return arg.stack ?? `${arg.name}: ${arg.message}`;
48+
}
49+
if (typeof arg === 'object' && arg !== null) {
50+
return JSON.stringify(arg);
51+
}
52+
return String(arg);
53+
} catch {
54+
return String(arg);
55+
}
56+
}),
57+
timestamp: Date.now(),
58+
});
59+
} catch {
60+
// Ignore errors during forwarding to avoid infinite loops
61+
}
62+
};
63+
64+
console.log = createForwarder('log');
65+
console.warn = createForwarder('warn');
66+
console.error = createForwarder('error');
67+
console.info = createForwarder('info');
68+
console.debug = createForwarder('debug');
69+
70+
// Return cleanup function to restore original console
71+
return () => {
72+
Object.assign(console, originalConsole);
73+
};
74+
};
75+
1976
export const getClient = async () => {
2077
const client = await getBridgeClient(getWSServer(), {
2178
runTests: async () => {
@@ -41,6 +98,7 @@ export const getClient = async () => {
4198
TestRunnerEvents | TestCollectorEvents | BundlerEvents
4299
> | null = null;
43100
let bundler: Bundler | null = null;
101+
let cleanupConsole: (() => void) | null = null;
44102

45103
try {
46104
collector = getTestCollector();
@@ -56,6 +114,11 @@ export const getClient = async () => {
56114
client.rpc.emitEvent(event.type, event);
57115
});
58116

117+
// Setup console forwarding to emit console events to host
118+
cleanupConsole = setupConsoleForwarding((type, data) => {
119+
client.rpc.emitEvent(type, data);
120+
});
121+
59122
await runSetupFiles({
60123
setupFiles: options.setupFiles ?? [],
61124
setupFilesAfterEnv: [],
@@ -94,6 +157,8 @@ export const getClient = async () => {
94157
});
95158
return result;
96159
} finally {
160+
// Restore original console
161+
cleanupConsole?.();
97162
collector?.dispose();
98163
runner?.dispose();
99164
events?.clearAllListeners();
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { store } from '../ui/state.js';
1+
import { resetRenderState } from './utils.js';
22

33
export const cleanup = (): void => {
4-
store.getState().setRenderedElement(null);
5-
store.getState().setOnLayoutCallback(null);
6-
store.getState().setOnRenderCallback(null);
4+
resetRenderState();
75
};

packages/runtime/src/render/index.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React from 'react';
22
import { store } from '../ui/state.js';
3+
import { batchedUpdate } from '../utils/batchedUpdate.js';
4+
import { resetRenderState } from './utils.js';
35
import type { RenderResult, RenderOptions } from './types.js';
46

57
const wrapElement = (
@@ -20,9 +22,7 @@ export const render = async (
2022

2123
// If an element is already rendered, unmount it first
2224
if (store.getState().renderedElement !== null) {
23-
store.getState().setRenderedElement(null);
24-
store.getState().setOnLayoutCallback(null);
25-
store.getState().setOnRenderCallback(null);
25+
resetRenderState();
2626
}
2727

2828
// Create a promise that resolves when the element is rendered.
@@ -36,15 +36,19 @@ export const render = async (
3636
);
3737
}, timeout);
3838

39-
store.getState().setOnRenderCallback(() => {
40-
clearTimeout(timeoutId);
41-
resolve();
39+
batchedUpdate(() => {
40+
store.getState().setOnRenderCallback(() => {
41+
clearTimeout(timeoutId);
42+
resolve();
43+
});
4244
});
4345
});
4446

4547
// Wrap and set the element in state (key is generated automatically)
4648
const wrappedElement = wrapElement(element, wrapper);
47-
store.getState().setRenderedElement(wrappedElement);
49+
batchedUpdate(() => {
50+
store.getState().setRenderedElement(wrappedElement);
51+
});
4852

4953
// Wait for useEffect to fire, ensuring all children are committed
5054
await renderPromise;
@@ -65,14 +69,18 @@ export const render = async (
6569
);
6670
}, timeout);
6771

68-
store.getState().setOnRenderCallback(() => {
69-
clearTimeout(timeoutId);
70-
resolve();
72+
batchedUpdate(() => {
73+
store.getState().setOnRenderCallback(() => {
74+
clearTimeout(timeoutId);
75+
resolve();
76+
});
7177
});
7278
});
7379

7480
const wrappedNewElement = wrapElement(newElement, wrapper);
75-
store.getState().updateRenderedElement(wrappedNewElement);
81+
batchedUpdate(() => {
82+
store.getState().updateRenderedElement(wrappedNewElement);
83+
});
7684

7785
// Wait for render
7886
await renderPromise;
@@ -82,10 +90,7 @@ export const render = async (
8290
if (store.getState().renderedElement === null) {
8391
return;
8492
}
85-
86-
store.getState().setRenderedElement(null);
87-
store.getState().setOnLayoutCallback(null);
88-
store.getState().setOnRenderCallback(null);
93+
resetRenderState();
8994
};
9095

9196
return {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { store } from '../ui/state.js';
2+
import { batchedUpdate } from '../utils/batchedUpdate.js';
3+
4+
/**
5+
* Resets render-related state in a batched update.
6+
* Clears the rendered element and associated callbacks.
7+
*/
8+
export const resetRenderState = (): void => {
9+
batchedUpdate(() => {
10+
store.getState().setRenderedElement(null);
11+
store.getState().setOnLayoutCallback(null);
12+
store.getState().setOnRenderCallback(null);
13+
});
14+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { unstable_batchedUpdates } from 'react-native';
2+
3+
/**
4+
* Batches state updates to avoid act() warnings in React Native.
5+
* Falls back to direct execution if unstable_batchedUpdates is unavailable.
6+
*/
7+
export const batchedUpdate = (fn: () => void): void => {
8+
if (unstable_batchedUpdates) {
9+
unstable_batchedUpdates(fn);
10+
} else {
11+
fn();
12+
}
13+
};

0 commit comments

Comments
 (0)