Skip to content

Commit ae5e7b8

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 - Add rate limiting (100 msgs/sec) to prevent flooding - 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 ae5e7b8

10 files changed

Lines changed: 284 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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,99 @@ 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';
911
import { getTestRunner, TestRunner } from '../runner/index.js';
1012
import { getTestCollector, TestCollector } from '../collector/index.js';
1113
import { combineEventEmitters, EventEmitter } from '../utils/emitter.js';
14+
import { createRateLimiter } from '../utils/rateLimiter.js';
1215
import { getWSServer } from './getWSServer.js';
1316
import { getBundler, evaluateModule, Bundler } from '../bundler/index.js';
1417
import { markTestsAsSkippedByName } from '../filtering/index.js';
1518
import { setup } from '../render/setup.js';
1619
import { runSetupFiles } from './setup-files.js';
1720
import { setClient } from './store.js';
1821

22+
type EmitEventFn = (type: ConsoleEvent['type'], data: ConsoleEvent) => void;
23+
24+
// Rate limit: 100 messages per second
25+
const CONSOLE_RATE_LIMIT = { maxCalls: 100, windowMs: 1000 };
26+
27+
// Console forwarding setup - intercepts console calls and emits events to host
28+
const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => {
29+
const originalConsole: Record<ConsoleLevel, typeof console.log> = {
30+
log: console.log,
31+
warn: console.warn,
32+
error: console.error,
33+
info: console.info,
34+
debug: console.debug,
35+
};
36+
37+
const rateLimiter = createRateLimiter(CONSOLE_RATE_LIMIT);
38+
let droppedCount = 0;
39+
let lastDropWarningTime = 0;
40+
41+
const createForwarder =
42+
(level: ConsoleLevel) =>
43+
(...args: unknown[]) => {
44+
// Call original console method
45+
originalConsole[level](...args);
46+
47+
// Check rate limit before forwarding
48+
if (!rateLimiter.tryAcquire()) {
49+
droppedCount++;
50+
// Log a warning at most once per second about dropped messages
51+
const now = Date.now();
52+
if (now - lastDropWarningTime > 1000) {
53+
lastDropWarningTime = now;
54+
originalConsole.warn(
55+
`[Harness] Console rate limit exceeded, ${droppedCount} messages dropped`
56+
);
57+
}
58+
return;
59+
}
60+
61+
// Forward to host via bridge
62+
try {
63+
emitEvent('console', {
64+
type: 'console',
65+
level,
66+
args: args.map((arg) => {
67+
try {
68+
if (arg instanceof Error) {
69+
return arg.stack ?? `${arg.name}: ${arg.message}`;
70+
}
71+
if (typeof arg === 'object' && arg !== null) {
72+
return JSON.stringify(arg);
73+
}
74+
return String(arg);
75+
} catch {
76+
return String(arg);
77+
}
78+
}),
79+
timestamp: Date.now(),
80+
});
81+
} catch {
82+
// Ignore errors during forwarding to avoid infinite loops
83+
}
84+
};
85+
86+
console.log = createForwarder('log');
87+
console.warn = createForwarder('warn');
88+
console.error = createForwarder('error');
89+
console.info = createForwarder('info');
90+
console.debug = createForwarder('debug');
91+
92+
// Return cleanup function to restore original console
93+
return () => {
94+
Object.assign(console, originalConsole);
95+
rateLimiter.reset();
96+
};
97+
};
98+
1999
export const getClient = async () => {
20100
const client = await getBridgeClient(getWSServer(), {
21101
runTests: async () => {
@@ -41,6 +121,7 @@ export const getClient = async () => {
41121
TestRunnerEvents | TestCollectorEvents | BundlerEvents
42122
> | null = null;
43123
let bundler: Bundler | null = null;
124+
let cleanupConsole: (() => void) | null = null;
44125

45126
try {
46127
collector = getTestCollector();
@@ -56,6 +137,11 @@ export const getClient = async () => {
56137
client.rpc.emitEvent(event.type, event);
57138
});
58139

140+
// Setup console forwarding to emit console events to host
141+
cleanupConsole = setupConsoleForwarding((type, data) => {
142+
client.rpc.emitEvent(type, data);
143+
});
144+
59145
await runSetupFiles({
60146
setupFiles: options.setupFiles ?? [],
61147
setupFilesAfterEnv: [],
@@ -94,6 +180,8 @@ export const getClient = async () => {
94180
});
95181
return result;
96182
} finally {
183+
// Restore original console
184+
cleanupConsole?.();
97185
collector?.dispose();
98186
runner?.dispose();
99187
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 {

0 commit comments

Comments
 (0)