Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/bridge/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from './shared/test-runner.js';
import type { TestCollectorEvents } from './shared/test-collector.js';
import type { BundlerEvents } from './shared/bundler.js';
import type { ConsoleEvent } from './shared/console.js';
import type { HarnessPlatform } from '@react-native-harness/platforms';

export type FileReference = {
Expand Down Expand Up @@ -103,6 +104,7 @@ export type {
SetupFileBundlingFailedEvent,
BundlerEvents,
} from './shared/bundler.js';
export type { ConsoleEvent, ConsoleLevel } from './shared/console.js';

export type DeviceDescriptor = {
platform: 'ios' | 'android' | 'vega';
Expand All @@ -114,7 +116,8 @@ export type DeviceDescriptor = {
export type BridgeEvents =
| TestCollectorEvents
| TestRunnerEvents
| BundlerEvents;
| BundlerEvents
| ConsoleEvent;

export type BridgeEventsMap = {
[K in BridgeEvents['type']]: (
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge/src/shared/console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug';

export type ConsoleEvent = {
type: 'console';
level: ConsoleLevel;
args: string[];
timestamp: number;
};
11 changes: 11 additions & 0 deletions packages/jest/src/harness.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
getBridgeServer,
BridgeServer,
BridgeServerEvents,
} from '@react-native-harness/bridge/server';
import {
HarnessContext,
Expand Down Expand Up @@ -31,6 +32,14 @@ export type Harness = {
restart: () => Promise<void>;
dispose: () => Promise<void>;
crashMonitor: CrashMonitor;
on: <T extends keyof BridgeServerEvents>(
event: T,
handler: BridgeServerEvents[T]
) => void;
off: <T extends keyof BridgeServerEvents>(
event: T,
handler: BridgeServerEvents[T]
) => void;
};

export const waitForAppReady = async (options: {
Expand Down Expand Up @@ -205,6 +214,8 @@ const getHarnessInternal = async (
restart,
dispose,
crashMonitor,
on: (event, handler) => serverBridge.on(event, handler),
off: (event, handler) => serverBridge.off(event, handler),
};
};

Expand Down
79 changes: 79 additions & 0 deletions packages/jest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TestWatcher,
} from 'jest-runner';
import pLimit from 'p-limit';
import chalk from 'chalk';
import { runHarnessTestFile } from './run.js';
import { Config as HarnessConfig } from '@react-native-harness/config';
import { type Harness } from './harness.js';
Expand All @@ -18,6 +19,72 @@ import { HarnessError } from '@react-native-harness/tools';
import { getErrorMessage } from './logs.js';
import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
import { NativeCrashError } from './errors.js';
import { ConsoleEvent } from '@react-native-harness/bridge';

// Printf-style string interpolation for console messages
const formatConsoleMessage = (args: string[]): string => {
if (!args || args.length === 0) return '';
if (args.length === 1) return args[0];

let template = String(args[0]);
let argIndex = 1;

// Replace %s, %d, %i, %o, %O, %j with corresponding arguments
template = template.replace(/%[sdioOj]/g, (match) => {
if (argIndex >= args.length) return match;
const arg = args[argIndex++];
switch (match) {
case '%s':
return String(arg);
case '%d':
return String(Number(arg));
case '%i':
return String(parseInt(String(arg), 10));
case '%o':
case '%O':
case '%j':
return typeof arg === 'string' ? arg : JSON.stringify(arg);
default:
return String(arg);
}
});

// Append remaining arguments
const remaining = args.slice(argIndex);
if (remaining.length > 0) {
template += ' ' + remaining.join(' ');
}

return template;
};

// Console event handler - prints console messages from device
const createConsoleEventHandler = (): ((event: ConsoleEvent) => void) => {
return (event: ConsoleEvent) => {
if (event.type === 'console') {
const message = formatConsoleMessage(event.args);
const tags: Record<string, string> = {
log: chalk.supportsColor
? chalk.reset.inverse.bold.cyan(' LOG ')
: 'LOG',
warn: chalk.supportsColor
? chalk.reset.inverse.bold.yellow(' WARN ')
: 'WARN',
error: chalk.supportsColor
? chalk.reset.inverse.bold.red(' ERROR ')
: 'ERROR',
info: chalk.supportsColor
? chalk.reset.inverse.bold.blue(' INFO ')
: 'INFO',
debug: chalk.supportsColor
? chalk.reset.inverse.bold.gray(' DEBUG ')
: 'DEBUG',
};
const tag = tags[event.level] || tags.log;
process.stderr.write(`${tag} ${message}\n`);
}
};
};

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

let consoleHandler: ((event: ConsoleEvent) => void) | null = null;

try {
// This is necessary as Harness may throw and we want to catch it and display a helpful error message.
await setup(this.#globalConfig);

const harness = global.HARNESS;
const harnessConfig = global.HARNESS_CONFIG;

// Setup console forwarding if not in silent mode
if (!this.#globalConfig.silent) {
consoleHandler = createConsoleEventHandler();
harness.on('event', consoleHandler);
}

return await this._createInBandTestRun(
tests,
watcher,
Expand All @@ -71,6 +146,10 @@ export default class JestHarness implements CallbackTestRunnerInterface {

throw error;
} finally {
// Cleanup console handler
if (consoleHandler && global.HARNESS) {
global.HARNESS.off('event', consoleHandler);
}
// This is necessary as Harness may throw and we want to catch it and display a helpful error message.
await teardown(this.#globalConfig);
}
Expand Down
65 changes: 65 additions & 0 deletions packages/runtime/src/client/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
TestCollectorEvents,
BundlerEvents,
TestExecutionOptions,
ConsoleLevel,
ConsoleEvent,
} from '@react-native-harness/bridge';
import { getBridgeClient } from '@react-native-harness/bridge/client';
import { store } from '../ui/state.js';
Expand All @@ -16,6 +18,61 @@ import { setup } from '../render/setup.js';
import { runSetupFiles } from './setup-files.js';
import { setClient } from './store.js';

type EmitEventFn = (type: ConsoleEvent['type'], data: ConsoleEvent) => void;

// Console forwarding setup - intercepts console calls and emits events to host
const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => {
const originalConsole: Record<ConsoleLevel, typeof console.log> = {
log: console.log,
warn: console.warn,
error: console.error,
info: console.info,
debug: console.debug,
};

const createForwarder =
(level: ConsoleLevel) =>
(...args: unknown[]) => {
// Call original console method
originalConsole[level](...args);

// Forward to host via bridge
try {
emitEvent('console', {
type: 'console',
level,
args: args.map((arg) => {
try {
if (arg instanceof Error) {
return arg.stack ?? `${arg.name}: ${arg.message}`;
}
if (typeof arg === 'object' && arg !== null) {
return JSON.stringify(arg);
}
return String(arg);
} catch {
return String(arg);
}
}),
timestamp: Date.now(),
});
} catch {
// Ignore errors during forwarding to avoid infinite loops
}
};

console.log = createForwarder('log');
console.warn = createForwarder('warn');
console.error = createForwarder('error');
console.info = createForwarder('info');
console.debug = createForwarder('debug');

// Return cleanup function to restore original console
return () => {
Object.assign(console, originalConsole);
};
};

export const getClient = async () => {
const client = await getBridgeClient(getWSServer(), {
runTests: async () => {
Expand All @@ -41,6 +98,7 @@ export const getClient = async () => {
TestRunnerEvents | TestCollectorEvents | BundlerEvents
> | null = null;
let bundler: Bundler | null = null;
let cleanupConsole: (() => void) | null = null;

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

// Setup console forwarding to emit console events to host
cleanupConsole = setupConsoleForwarding((type, data) => {
client.rpc.emitEvent(type, data);
});

await runSetupFiles({
setupFiles: options.setupFiles ?? [],
setupFilesAfterEnv: [],
Expand Down Expand Up @@ -94,6 +157,8 @@ export const getClient = async () => {
});
return result;
} finally {
// Restore original console
cleanupConsole?.();
collector?.dispose();
runner?.dispose();
events?.clearAllListeners();
Expand Down
6 changes: 2 additions & 4 deletions packages/runtime/src/render/cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { store } from '../ui/state.js';
import { resetRenderState } from './utils.js';

export const cleanup = (): void => {
store.getState().setRenderedElement(null);
store.getState().setOnLayoutCallback(null);
store.getState().setOnRenderCallback(null);
resetRenderState();
};
35 changes: 20 additions & 15 deletions packages/runtime/src/render/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import { store } from '../ui/state.js';
import { batchedUpdate } from '../utils/batchedUpdate.js';
import { resetRenderState } from './utils.js';
import type { RenderResult, RenderOptions } from './types.js';

const wrapElement = (
Expand All @@ -20,9 +22,7 @@ export const render = async (

// If an element is already rendered, unmount it first
if (store.getState().renderedElement !== null) {
store.getState().setRenderedElement(null);
store.getState().setOnLayoutCallback(null);
store.getState().setOnRenderCallback(null);
resetRenderState();
}

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

store.getState().setOnRenderCallback(() => {
clearTimeout(timeoutId);
resolve();
batchedUpdate(() => {
store.getState().setOnRenderCallback(() => {
clearTimeout(timeoutId);
resolve();
});
});
});

// Wrap and set the element in state (key is generated automatically)
const wrappedElement = wrapElement(element, wrapper);
store.getState().setRenderedElement(wrappedElement);
batchedUpdate(() => {
store.getState().setRenderedElement(wrappedElement);
});

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

store.getState().setOnRenderCallback(() => {
clearTimeout(timeoutId);
resolve();
batchedUpdate(() => {
store.getState().setOnRenderCallback(() => {
clearTimeout(timeoutId);
resolve();
});
});
});

const wrappedNewElement = wrapElement(newElement, wrapper);
store.getState().updateRenderedElement(wrappedNewElement);
batchedUpdate(() => {
store.getState().updateRenderedElement(wrappedNewElement);
});

// Wait for render
await renderPromise;
Expand All @@ -82,10 +90,7 @@ export const render = async (
if (store.getState().renderedElement === null) {
return;
}

store.getState().setRenderedElement(null);
store.getState().setOnLayoutCallback(null);
store.getState().setOnRenderCallback(null);
resetRenderState();
};

return {
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime/src/render/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { store } from '../ui/state.js';
import { batchedUpdate } from '../utils/batchedUpdate.js';

/**
* Resets render-related state in a batched update.
* Clears the rendered element and associated callbacks.
*/
export const resetRenderState = (): void => {
batchedUpdate(() => {
store.getState().setRenderedElement(null);
store.getState().setOnLayoutCallback(null);
store.getState().setOnRenderCallback(null);
});
};
13 changes: 13 additions & 0 deletions packages/runtime/src/utils/batchedUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { unstable_batchedUpdates } from 'react-native';

/**
* Batches state updates to avoid act() warnings in React Native.
* Falls back to direct execution if unstable_batchedUpdates is unavailable.
Comment on lines +4 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not see 'act' warnings as this is a normal React Native app. Could you provide a code sample where it happens?

*/
export const batchedUpdate = (fn: () => void): void => {
if (unstable_batchedUpdates) {
unstable_batchedUpdates(fn);
} else {
fn();
}
};