@react-native-harness/jest 1.4.0-rc.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/bridge.test.js +6 -2
- package/dist/__tests__/crash-monitor.test.js +64 -0
- package/dist/__tests__/environment-reset.test.d.ts +2 -0
- package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
- package/dist/__tests__/environment-reset.test.js +238 -0
- package/dist/__tests__/execute-run.test.js +250 -4
- package/dist/__tests__/format-harness-error.test.d.ts +2 -0
- package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
- package/dist/__tests__/format-harness-error.test.js +27 -0
- package/dist/__tests__/harness-cache.test.js +62 -41
- package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
- package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
- package/dist/__tests__/harness-session-signal.test.js +298 -0
- package/dist/__tests__/harness-session.test.js +161 -2
- package/dist/__tests__/index.test.js +3 -1
- package/dist/__tests__/metro-port.test.js +2 -0
- package/dist/__tests__/run.test.d.ts +2 -0
- package/dist/__tests__/run.test.d.ts.map +1 -0
- package/dist/__tests__/run.test.js +48 -0
- package/dist/crash-monitor.d.ts +7 -0
- package/dist/crash-monitor.d.ts.map +1 -1
- package/dist/crash-monitor.js +19 -0
- package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
- package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
- package/dist/diagnostics/__tests__/derivers.test.js +248 -0
- package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
- package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
- package/dist/diagnostics/__tests__/report.test.js +97 -0
- package/dist/diagnostics/derivers.d.ts +24 -0
- package/dist/diagnostics/derivers.d.ts.map +1 -0
- package/dist/diagnostics/derivers.js +146 -0
- package/dist/diagnostics/index.d.ts +3 -0
- package/dist/diagnostics/index.d.ts.map +1 -0
- package/dist/diagnostics/index.js +2 -0
- package/dist/diagnostics/report.d.ts +7 -0
- package/dist/diagnostics/report.d.ts.map +1 -0
- package/dist/diagnostics/report.js +84 -0
- package/dist/environment-reset.d.ts +51 -0
- package/dist/environment-reset.d.ts.map +1 -0
- package/dist/environment-reset.js +83 -0
- package/dist/execute-run.d.ts.map +1 -1
- package/dist/execute-run.js +121 -69
- package/dist/format-harness-error.d.ts +5 -0
- package/dist/format-harness-error.d.ts.map +1 -0
- package/dist/format-harness-error.js +43 -0
- package/dist/harness-session.d.ts +26 -1
- package/dist/harness-session.d.ts.map +1 -1
- package/dist/harness-session.js +260 -87
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +21 -4
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/src/__tests__/bridge.test.ts +8 -3
- package/src/__tests__/crash-monitor.test.ts +84 -0
- package/src/__tests__/environment-reset.test.ts +308 -0
- package/src/__tests__/execute-run.test.ts +327 -4
- package/src/__tests__/format-harness-error.test.ts +32 -0
- package/src/__tests__/harness-cache.test.ts +91 -0
- package/src/__tests__/harness-session.test.ts +211 -1
- package/src/__tests__/index.test.ts +3 -1
- package/src/__tests__/metro-port.test.ts +2 -0
- package/src/__tests__/run.test.ts +74 -0
- package/src/crash-monitor.ts +28 -0
- package/src/diagnostics/__tests__/derivers.test.ts +300 -0
- package/src/diagnostics/__tests__/report.test.ts +128 -0
- package/src/diagnostics/derivers.ts +170 -0
- package/src/diagnostics/index.ts +2 -0
- package/src/diagnostics/report.ts +124 -0
- package/src/environment-reset.ts +138 -0
- package/src/execute-run.ts +136 -77
- package/src/format-harness-error.ts +73 -0
- package/src/harness-session.ts +369 -131
- package/src/run.ts +23 -4
- package/tsconfig.json +3 -0
- package/tsconfig.lib.json +3 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { DiagnosticEntry } from '@react-native-harness/tools';
|
|
4
|
+
import { getHarnessRootPath } from '@react-native-harness/tools';
|
|
5
|
+
|
|
6
|
+
type TraceEvent = {
|
|
7
|
+
name: string;
|
|
8
|
+
ph: 'X' | 'i';
|
|
9
|
+
ts: number;
|
|
10
|
+
dur?: number;
|
|
11
|
+
s?: 'g';
|
|
12
|
+
pid: number;
|
|
13
|
+
tid: number;
|
|
14
|
+
args?: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const TRACE_PID = 1;
|
|
18
|
+
const TRACE_TID = 1;
|
|
19
|
+
|
|
20
|
+
const toTraceEvent = (entry: DiagnosticEntry): TraceEvent => {
|
|
21
|
+
if (entry.kind === 'mark') {
|
|
22
|
+
return {
|
|
23
|
+
name: entry.label,
|
|
24
|
+
ph: 'i',
|
|
25
|
+
s: 'g',
|
|
26
|
+
ts: entry.startTime * 1000,
|
|
27
|
+
pid: TRACE_PID,
|
|
28
|
+
tid: TRACE_TID,
|
|
29
|
+
args: entry.attrs,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
name: entry.label,
|
|
35
|
+
ph: 'X',
|
|
36
|
+
ts: entry.startTime * 1000,
|
|
37
|
+
dur: entry.duration * 1000,
|
|
38
|
+
pid: TRACE_PID,
|
|
39
|
+
tid: TRACE_TID,
|
|
40
|
+
args: entry.attrs,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const writeTraceFile = async (
|
|
45
|
+
entries: DiagnosticEntry[],
|
|
46
|
+
options: { projectRoot: string; runId: string }
|
|
47
|
+
): Promise<string> => {
|
|
48
|
+
const dir = path.join(getHarnessRootPath(options.projectRoot), 'diagnostics');
|
|
49
|
+
await fs.mkdir(dir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const filePath = path.join(dir, `${options.runId}.json`);
|
|
52
|
+
const trace = { traceEvents: entries.map(toTraceEvent) };
|
|
53
|
+
|
|
54
|
+
await fs.writeFile(filePath, JSON.stringify(trace), 'utf-8');
|
|
55
|
+
|
|
56
|
+
return filePath;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const formatDuration = (ms: number): string => {
|
|
60
|
+
if (ms >= 1000) {
|
|
61
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
62
|
+
}
|
|
63
|
+
return `${Math.round(ms)}ms`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getNamespace = (label: string): string => label.split('.')[0] ?? label;
|
|
67
|
+
|
|
68
|
+
type LabelStats = {
|
|
69
|
+
label: string;
|
|
70
|
+
count: number;
|
|
71
|
+
totalMs: number;
|
|
72
|
+
maxMs: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const printSummary = (
|
|
76
|
+
entries: DiagnosticEntry[],
|
|
77
|
+
log: (line: string) => void
|
|
78
|
+
): void => {
|
|
79
|
+
if (entries.length === 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const statsByLabel = new Map<string, LabelStats>();
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const existing = statsByLabel.get(entry.label);
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.count += 1;
|
|
88
|
+
existing.totalMs += entry.duration;
|
|
89
|
+
existing.maxMs = Math.max(existing.maxMs, entry.duration);
|
|
90
|
+
} else {
|
|
91
|
+
statsByLabel.set(entry.label, {
|
|
92
|
+
label: entry.label,
|
|
93
|
+
count: 1,
|
|
94
|
+
totalMs: entry.duration,
|
|
95
|
+
maxMs: entry.duration,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const byNamespace = new Map<string, LabelStats[]>();
|
|
101
|
+
for (const stats of statsByLabel.values()) {
|
|
102
|
+
const namespace = getNamespace(stats.label);
|
|
103
|
+
const group = byNamespace.get(namespace);
|
|
104
|
+
if (group) {
|
|
105
|
+
group.push(stats);
|
|
106
|
+
} else {
|
|
107
|
+
byNamespace.set(namespace, [stats]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log('Diagnostics summary:');
|
|
112
|
+
const sortedNamespaces = [...byNamespace.entries()].sort(([a], [b]) =>
|
|
113
|
+
a.localeCompare(b)
|
|
114
|
+
);
|
|
115
|
+
for (const [namespace, group] of sortedNamespaces) {
|
|
116
|
+
log(` ${namespace}`);
|
|
117
|
+
group.sort((a, b) => b.totalMs - a.totalMs);
|
|
118
|
+
for (const stats of group) {
|
|
119
|
+
log(
|
|
120
|
+
` ${stats.label}: count=${stats.count} total=${formatDuration(stats.totalMs)} max=${formatDuration(stats.maxMs)}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { getTimeoutSignal } from '@react-native-harness/tools';
|
|
2
|
+
import type { Config as HarnessConfig } from '@react-native-harness/config';
|
|
3
|
+
|
|
4
|
+
const ignorePromiseRejection = () => undefined;
|
|
5
|
+
|
|
6
|
+
export type ResetStrategyKind = 'process' | 'runtime';
|
|
7
|
+
|
|
8
|
+
export type EnvironmentResetStrategy = {
|
|
9
|
+
readonly kind: ResetStrategyKind;
|
|
10
|
+
// Post-condition: bridge connected, runtime ready, crash-monitor state consistent.
|
|
11
|
+
reset(ctx: { testFilePath: string }): Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalizes the `resetEnvironmentBetweenTestFiles` config value into a
|
|
16
|
+
* strategy kind, or `null` when no reset should happen between test files
|
|
17
|
+
* (i.e. the value is `false`).
|
|
18
|
+
*/
|
|
19
|
+
export const resolveResetStrategyKind = (
|
|
20
|
+
value: HarnessConfig['resetEnvironmentBetweenTestFiles']
|
|
21
|
+
): ResetStrategyKind | null => {
|
|
22
|
+
if (value === false) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (value === true) {
|
|
26
|
+
return 'process';
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ProcessResetStrategyDeps = {
|
|
32
|
+
// Shared with restartApp; killing and relaunching the app process.
|
|
33
|
+
restart: (testFilePath: string) => Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The 'process' strategy: kill and cold-restart the app process. This is
|
|
38
|
+
* the same implementation `restartApp` uses for crash/timeout recovery.
|
|
39
|
+
*/
|
|
40
|
+
export const createProcessResetStrategy = (
|
|
41
|
+
deps: ProcessResetStrategyDeps
|
|
42
|
+
): EnvironmentResetStrategy => ({
|
|
43
|
+
kind: 'process',
|
|
44
|
+
reset: ({ testFilePath }) => deps.restart(testFilePath),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type RuntimeResetConnection = {
|
|
48
|
+
resetEnvironment: () => Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RuntimeResetStrategyDeps = {
|
|
52
|
+
// Returns the active app connection, or null if there isn't one.
|
|
53
|
+
getConnection: () => RuntimeResetConnection | null;
|
|
54
|
+
// Opens a window that suppresses crash-monitor disconnect handling;
|
|
55
|
+
// returns a closer to end the window.
|
|
56
|
+
expectDisconnect: () => () => void;
|
|
57
|
+
// Resolves the next time the bridge reports a 'connected' event, or
|
|
58
|
+
// rejects when `signal` aborts.
|
|
59
|
+
waitForReconnect: (signal: AbortSignal) => Promise<void>;
|
|
60
|
+
// Identifies RPC rejections caused by the bridge disconnecting. Inside the
|
|
61
|
+
// expected-disconnect window these mean the reload tore the runtime down
|
|
62
|
+
// before the RPC ack flushed — the reload is in flight, not failed.
|
|
63
|
+
isDisconnectError: (error: unknown) => boolean;
|
|
64
|
+
// Reconnect timeout in ms (the configured bundleStartTimeout).
|
|
65
|
+
timeoutMs: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The 'runtime' strategy: reload the JS runtime in place (DevSettings.reload()
|
|
70
|
+
* on native, window.location.reload() on web) via the resetEnvironment RPC,
|
|
71
|
+
* and wait for the bridge to reconnect. The app process stays alive
|
|
72
|
+
* throughout, so the crash monitor's expected-disconnect window is used to
|
|
73
|
+
* avoid misclassifying the intentional disconnect as a runtime failure.
|
|
74
|
+
*/
|
|
75
|
+
export const createRuntimeResetStrategy = (
|
|
76
|
+
deps: RuntimeResetStrategyDeps
|
|
77
|
+
): EnvironmentResetStrategy => ({
|
|
78
|
+
kind: 'runtime',
|
|
79
|
+
reset: async () => {
|
|
80
|
+
const connection = deps.getConnection();
|
|
81
|
+
if (!connection) {
|
|
82
|
+
throw new Error('No active app connection to reset the runtime on');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const closeWindow = deps.expectDisconnect();
|
|
86
|
+
try {
|
|
87
|
+
// Start listening for the reconnect before firing the RPC, so a fast
|
|
88
|
+
// reload can't reconnect before we're watching for it.
|
|
89
|
+
const reconnected = deps.waitForReconnect(getTimeoutSignal(deps.timeoutMs));
|
|
90
|
+
// Observe the rejection even when the RPC throws first, so the timeout
|
|
91
|
+
// abort firing later can't become an unhandled rejection. (.catch()
|
|
92
|
+
// returns a new promise; the original is what gets awaited below.)
|
|
93
|
+
reconnected.catch(ignorePromiseRejection);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await connection.resetEnvironment();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// The reload can tear the runtime down before the RPC ack flushes.
|
|
99
|
+
// A disconnect-flavored rejection means the reload is in flight, so
|
|
100
|
+
// keep waiting for the reconnect; anything else is a real failure.
|
|
101
|
+
if (!deps.isDisconnectError(error)) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await reconnected;
|
|
107
|
+
} finally {
|
|
108
|
+
closeWindow();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type EscalationOptions = {
|
|
114
|
+
// Invoked when the primary strategy fails and the fallback is about to run.
|
|
115
|
+
onEscalate?: (error: unknown) => void;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wraps a primary strategy so that any failure (no active connection, RPC
|
|
120
|
+
* error, reconnect timeout, ...) falls back to running `fallback` instead of
|
|
121
|
+
* propagating the error. The composite reports the primary's `kind`, since
|
|
122
|
+
* that's the strategy that was requested.
|
|
123
|
+
*/
|
|
124
|
+
export const withEscalation = (
|
|
125
|
+
primary: EnvironmentResetStrategy,
|
|
126
|
+
fallback: EnvironmentResetStrategy,
|
|
127
|
+
options: EscalationOptions = {}
|
|
128
|
+
): EnvironmentResetStrategy => ({
|
|
129
|
+
kind: primary.kind,
|
|
130
|
+
reset: async (ctx) => {
|
|
131
|
+
try {
|
|
132
|
+
await primary.reset(ctx);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
options.onEscalate?.(error);
|
|
135
|
+
await fallback.reset(ctx);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
package/src/execute-run.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { type HarnessSession, type HarnessRunState } from './harness-session.js';
|
|
10
10
|
import { runHarnessTestFile } from './run.js';
|
|
11
|
+
import { resolveResetStrategyKind } from './environment-reset.js';
|
|
11
12
|
import {
|
|
12
13
|
NativeCrashError,
|
|
13
14
|
RuntimeDisconnectError,
|
|
@@ -22,11 +23,17 @@ import type {
|
|
|
22
23
|
TestRunnerEvents,
|
|
23
24
|
TestRunnerTestFinishedEvent,
|
|
24
25
|
TestRunnerTestStartedEvent,
|
|
26
|
+
TestSuiteResult,
|
|
25
27
|
} from '@react-native-harness/bridge';
|
|
26
28
|
import {
|
|
27
29
|
createPlatformSkippedTestResult,
|
|
28
30
|
shouldRunHarnessTestFile,
|
|
29
31
|
} from './test-file-platform-filter.js';
|
|
32
|
+
import { formatHarnessErrorMessage } from './format-harness-error.js';
|
|
33
|
+
import { logger } from '@react-native-harness/tools';
|
|
34
|
+
import { printSummary, writeTraceFile } from './diagnostics/index.js';
|
|
35
|
+
|
|
36
|
+
const diagnosticsLogger = logger.child('diagnostics');
|
|
30
37
|
|
|
31
38
|
type EmitTestEvent = <Name extends keyof TestEvents>(
|
|
32
39
|
eventName: Name,
|
|
@@ -86,8 +93,10 @@ const emitHarnessTestFinished = async (
|
|
|
86
93
|
emitEvent: EmitTestEvent,
|
|
87
94
|
event: TestRunnerTestFinishedEvent,
|
|
88
95
|
): Promise<void> => {
|
|
89
|
-
const failureMessage = event.error?.message;
|
|
90
96
|
const codeFrame = event.error?.codeFrame;
|
|
97
|
+
const failureMessage = formatHarnessErrorMessage(event.error, {
|
|
98
|
+
testStartedAt: event.startedAt,
|
|
99
|
+
});
|
|
91
100
|
const location = codeFrame?.location
|
|
92
101
|
? { column: codeFrame.location.column, line: codeFrame.location.row }
|
|
93
102
|
: null;
|
|
@@ -95,9 +104,7 @@ const emitHarnessTestFinished = async (
|
|
|
95
104
|
ancestorTitles: event.ancestorTitles,
|
|
96
105
|
duration: event.duration,
|
|
97
106
|
failureDetails: [],
|
|
98
|
-
failureMessages: failureMessage
|
|
99
|
-
? [`${failureMessage}${codeFrame ? `\n\n${codeFrame.content}` : ''}`]
|
|
100
|
-
: [],
|
|
107
|
+
failureMessages: failureMessage ? [failureMessage] : [],
|
|
101
108
|
fullName: event.fullName,
|
|
102
109
|
location,
|
|
103
110
|
numPassingAsserts: event.status === 'passed' ? 1 : 0,
|
|
@@ -114,6 +121,18 @@ const isHarnessCaseEvent = (
|
|
|
114
121
|
): event is TestRunnerTestStartedEvent | TestRunnerTestFinishedEvent =>
|
|
115
122
|
event.type === 'test-started' || event.type === 'test-finished';
|
|
116
123
|
|
|
124
|
+
const TIMEOUT_ERROR_NAMES = new Set([
|
|
125
|
+
'SuiteHookTimeoutError',
|
|
126
|
+
'TestCaseTimeoutError',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const hasRuntimeTimeout = (result: TestSuiteResult): boolean =>
|
|
130
|
+
(result.error ? TIMEOUT_ERROR_NAMES.has(result.error.name) : false) ||
|
|
131
|
+
result.tests.some((test) =>
|
|
132
|
+
test.error ? TIMEOUT_ERROR_NAMES.has(test.error.name) : false,
|
|
133
|
+
) ||
|
|
134
|
+
result.suites.some(hasRuntimeTimeout);
|
|
135
|
+
|
|
117
136
|
export const executeRun = async (
|
|
118
137
|
session: HarnessSession,
|
|
119
138
|
tests: Array<Test>,
|
|
@@ -122,6 +141,8 @@ export const executeRun = async (
|
|
|
122
141
|
globalConfig: Config.GlobalConfig,
|
|
123
142
|
): Promise<void> => {
|
|
124
143
|
const runId = randomUUID();
|
|
144
|
+
const diagnostics = session.diagnostics.child({ runId });
|
|
145
|
+
const runSpan = diagnostics.start('run.total', { runId });
|
|
125
146
|
const startTime = Date.now();
|
|
126
147
|
const watchMode = globalConfig.watch || globalConfig.watchAll;
|
|
127
148
|
const rootDir = globalConfig.rootDir ?? process.cwd();
|
|
@@ -145,6 +166,7 @@ export const executeRun = async (
|
|
|
145
166
|
testFiles,
|
|
146
167
|
watchMode,
|
|
147
168
|
coverageEnabled: globalConfig.collectCoverage,
|
|
169
|
+
completed: false,
|
|
148
170
|
summary,
|
|
149
171
|
status: summary.failed > 0 ? 'failed' : 'passed',
|
|
150
172
|
...overrides,
|
|
@@ -168,12 +190,15 @@ export const executeRun = async (
|
|
|
168
190
|
host: session.config.host?.trim() || undefined,
|
|
169
191
|
});
|
|
170
192
|
|
|
171
|
-
const
|
|
193
|
+
const resetStrategyKind = resolveResetStrategyKind(
|
|
194
|
+
session.config.resetEnvironmentBetweenTestFiles,
|
|
195
|
+
);
|
|
172
196
|
const platformId = session.context.platform.platformId;
|
|
173
197
|
const knownPlatformIds = new Set(
|
|
174
198
|
session.config.runners.map((runner) => runner.platformId),
|
|
175
199
|
);
|
|
176
200
|
let isFirstTest = true;
|
|
201
|
+
let shouldRestartAfterTimeout = false;
|
|
177
202
|
let runError: unknown;
|
|
178
203
|
|
|
179
204
|
try {
|
|
@@ -200,22 +225,86 @@ export const executeRun = async (
|
|
|
200
225
|
};
|
|
201
226
|
|
|
202
227
|
await session.callHook('test-file:started', { runId, file: relativeTestPath });
|
|
228
|
+
const fileSpan = diagnostics.start('run.file', { file: relativeTestPath });
|
|
229
|
+
let fileStatus: 'passed' | 'failed' | 'skipped' | 'todo' = 'failed';
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
if (
|
|
233
|
+
!shouldRunHarnessTestFile(test.path, platformId, knownPlatformIds)
|
|
234
|
+
) {
|
|
235
|
+
try {
|
|
236
|
+
await emitEvent('test-file-start', test);
|
|
237
|
+
const skippedResult = createPlatformSkippedTestResult(test.path);
|
|
238
|
+
applyJestResultToSummary(summary, skippedResult);
|
|
239
|
+
updateRunState();
|
|
240
|
+
fileStatus = 'skipped';
|
|
241
|
+
await emitTestFileFinished({
|
|
242
|
+
status: 'skipped',
|
|
243
|
+
duration: Date.now() - fileStartedAt,
|
|
244
|
+
result: null,
|
|
245
|
+
});
|
|
246
|
+
await emitEvent('test-file-success', test, skippedResult);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
fileStatus = 'failed';
|
|
249
|
+
if (!emittedTestFileFinished) {
|
|
250
|
+
await emitTestFileFinished({
|
|
251
|
+
status: 'failed',
|
|
252
|
+
duration: Date.now() - fileStartedAt,
|
|
253
|
+
result: null,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
updateRunState({ error: err });
|
|
257
|
+
await emitEvent('test-file-failure', test, buildTestFailure(err));
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
203
261
|
|
|
204
|
-
if (
|
|
205
|
-
!shouldRunHarnessTestFile(test.path, platformId, knownPlatformIds)
|
|
206
|
-
) {
|
|
207
262
|
try {
|
|
263
|
+
if (shouldRestartAfterTimeout) {
|
|
264
|
+
// Recovery from a runtime timeout always uses the (stronger)
|
|
265
|
+
// process restart, never the configured reset strategy.
|
|
266
|
+
await session.restartApp(test.path);
|
|
267
|
+
shouldRestartAfterTimeout = false;
|
|
268
|
+
} else if (resetStrategyKind !== null && !isFirstTest) {
|
|
269
|
+
await session.resetEnvironment(test.path);
|
|
270
|
+
}
|
|
271
|
+
isFirstTest = false;
|
|
272
|
+
|
|
273
|
+
session.flushClientLogs();
|
|
208
274
|
await emitEvent('test-file-start', test);
|
|
209
|
-
|
|
210
|
-
|
|
275
|
+
await session.ensureAppReady(test.path);
|
|
276
|
+
|
|
277
|
+
// Crash detection is handled inside session.runTestFile; NativeCrashError
|
|
278
|
+
// propagates here if a crash wins the race.
|
|
279
|
+
const result = await runHarnessTestFile({
|
|
280
|
+
testPath: test.path,
|
|
281
|
+
session,
|
|
282
|
+
globalConfig,
|
|
283
|
+
projectConfig: test.context.config,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
applyJestResultToSummary(summary, result.jestResult);
|
|
287
|
+
const clientLogs = session.flushClientLogs();
|
|
288
|
+
if (clientLogs.length > 0) {
|
|
289
|
+
result.jestResult.console = clientLogs;
|
|
290
|
+
}
|
|
211
291
|
updateRunState();
|
|
292
|
+
fileStatus = result.harnessResult.status;
|
|
212
293
|
await emitTestFileFinished({
|
|
213
|
-
status:
|
|
214
|
-
duration:
|
|
215
|
-
result:
|
|
294
|
+
status: result.harnessResult.status,
|
|
295
|
+
duration: result.duration,
|
|
296
|
+
result: result.harnessResult,
|
|
216
297
|
});
|
|
217
|
-
|
|
298
|
+
const didRuntimeTimeout = hasRuntimeTimeout(result.harnessResult);
|
|
299
|
+
shouldRestartAfterTimeout = didRuntimeTimeout;
|
|
300
|
+
await caseEventChain;
|
|
301
|
+
await emitEvent('test-file-success', test, result.jestResult);
|
|
302
|
+
if (didRuntimeTimeout) {
|
|
303
|
+
await session.restartApp(test.path);
|
|
304
|
+
shouldRestartAfterTimeout = false;
|
|
305
|
+
}
|
|
218
306
|
} catch (err) {
|
|
307
|
+
fileStatus = 'failed';
|
|
219
308
|
if (!emittedTestFileFinished) {
|
|
220
309
|
await emitTestFileFinished({
|
|
221
310
|
status: 'failed',
|
|
@@ -223,72 +312,29 @@ export const executeRun = async (
|
|
|
223
312
|
result: null,
|
|
224
313
|
});
|
|
225
314
|
}
|
|
226
|
-
updateRunState({ error: err });
|
|
227
|
-
await emitEvent('test-file-failure', test, buildTestFailure(err));
|
|
228
|
-
}
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
try {
|
|
233
|
-
if (shouldResetEnv && !isFirstTest) {
|
|
234
|
-
await session.restartApp(test.path);
|
|
235
|
-
}
|
|
236
|
-
isFirstTest = false;
|
|
237
|
-
|
|
238
|
-
session.flushClientLogs();
|
|
239
|
-
await emitEvent('test-file-start', test);
|
|
240
|
-
await session.ensureAppReady(test.path);
|
|
241
|
-
|
|
242
|
-
// Crash detection is handled inside session.runTestFile; NativeCrashError
|
|
243
|
-
// propagates here if a crash wins the race.
|
|
244
|
-
const result = await runHarnessTestFile({
|
|
245
|
-
testPath: test.path,
|
|
246
|
-
session,
|
|
247
|
-
globalConfig,
|
|
248
|
-
projectConfig: test.context.config,
|
|
249
|
-
});
|
|
250
315
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
await emitTestFileFinished({
|
|
258
|
-
status: result.harnessResult.status,
|
|
259
|
-
duration: result.duration,
|
|
260
|
-
result: result.harnessResult,
|
|
261
|
-
});
|
|
262
|
-
await caseEventChain;
|
|
263
|
-
await emitEvent('test-file-success', test, result.jestResult);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
if (!emittedTestFileFinished) {
|
|
266
|
-
await emitTestFileFinished({
|
|
267
|
-
status: 'failed',
|
|
268
|
-
duration: Date.now() - fileStartedAt,
|
|
269
|
-
result: null,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
316
|
+
const isRuntimeFailure =
|
|
317
|
+
err instanceof NativeCrashError ||
|
|
318
|
+
err instanceof RuntimeDisconnectError ||
|
|
319
|
+
err instanceof StartupStallError ||
|
|
320
|
+
err instanceof AppBridgeDisconnectedError ||
|
|
321
|
+
err instanceof DeviceNotRespondingError;
|
|
272
322
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
err instanceof AppBridgeDisconnectedError ||
|
|
278
|
-
err instanceof DeviceNotRespondingError;
|
|
323
|
+
if (isRuntimeFailure) {
|
|
324
|
+
summary.failed += 1;
|
|
325
|
+
updateRunState();
|
|
326
|
+
}
|
|
279
327
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
328
|
+
if (err instanceof NativeCrashError || err instanceof RuntimeDisconnectError) {
|
|
329
|
+
session.resetCrashState();
|
|
330
|
+
}
|
|
284
331
|
|
|
285
|
-
|
|
286
|
-
|
|
332
|
+
updateRunState({ error: isRuntimeFailure ? undefined : err });
|
|
333
|
+
await caseEventChain;
|
|
334
|
+
await emitEvent('test-file-failure', test, buildTestFailure(err));
|
|
287
335
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
await caseEventChain;
|
|
291
|
-
await emitEvent('test-file-failure', test, buildTestFailure(err));
|
|
336
|
+
} finally {
|
|
337
|
+
fileSpan.end({ status: fileStatus });
|
|
292
338
|
}
|
|
293
339
|
}
|
|
294
340
|
} catch (err) {
|
|
@@ -296,18 +342,31 @@ export const executeRun = async (
|
|
|
296
342
|
if (!(err instanceof CancelRun)) throw err;
|
|
297
343
|
} finally {
|
|
298
344
|
const runState = updateRunState(
|
|
299
|
-
runError != null ? { error: runError, status: 'failed' } : {},
|
|
345
|
+
runError != null ? { completed: true, error: runError, status: 'failed' } : { completed: true },
|
|
300
346
|
);
|
|
347
|
+
const finalStatus = runState.status ?? (runError != null ? 'failed' : 'passed');
|
|
301
348
|
await session.callHook('run:finished', {
|
|
302
349
|
runId,
|
|
303
350
|
startTime,
|
|
304
351
|
duration: Date.now() - startTime,
|
|
305
352
|
testFiles,
|
|
306
353
|
summary,
|
|
307
|
-
status:
|
|
354
|
+
status: finalStatus,
|
|
308
355
|
...(runError != null ? { error: runError } : {}),
|
|
309
356
|
});
|
|
310
357
|
await caseEventChain;
|
|
311
358
|
unsubscribe();
|
|
359
|
+
runSpan.end({ runId, status: finalStatus });
|
|
360
|
+
|
|
361
|
+
if (session.diagnostics.enabled) {
|
|
362
|
+
const entries = session.diagnostics.flush();
|
|
363
|
+
try {
|
|
364
|
+
printSummary(entries, (line) => diagnosticsLogger.info(line));
|
|
365
|
+
const tracePath = await writeTraceFile(entries, { projectRoot: rootDir, runId });
|
|
366
|
+
diagnosticsLogger.info(`wrote diagnostics trace to ${tracePath}`);
|
|
367
|
+
} catch (reportError) {
|
|
368
|
+
diagnosticsLogger.warn('failed to write diagnostics report: %s', reportError);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
312
371
|
}
|
|
313
372
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { SerializedError } from '@react-native-harness/bridge';
|
|
2
|
+
|
|
3
|
+
const formatPendingPromiseStack = (stack: string): string =>
|
|
4
|
+
stack
|
|
5
|
+
.split('\n')
|
|
6
|
+
.map((line) => ` ${line}`)
|
|
7
|
+
.join('\n');
|
|
8
|
+
|
|
9
|
+
const formatPendingPromises = (
|
|
10
|
+
pendingPromises: NonNullable<
|
|
11
|
+
NonNullable<SerializedError['diagnostics']>['pendingPromises']
|
|
12
|
+
>,
|
|
13
|
+
testStartedAt?: number,
|
|
14
|
+
): string | null => {
|
|
15
|
+
if (pendingPromises.total === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lines = [`Pending promises at timeout: ${pendingPromises.total}`];
|
|
20
|
+
|
|
21
|
+
if (pendingPromises.items.length < pendingPromises.total) {
|
|
22
|
+
lines.push(
|
|
23
|
+
`Showing ${pendingPromises.items.length} of ${pendingPromises.total} pending promises.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const promise of pendingPromises.items) {
|
|
28
|
+
const age =
|
|
29
|
+
testStartedAt !== undefined
|
|
30
|
+
? `, created ${Math.max(0, promise.createdAt - testStartedAt)}ms after test start`
|
|
31
|
+
: '';
|
|
32
|
+
|
|
33
|
+
lines.push('');
|
|
34
|
+
lines.push(`Promise #${promise.id}${age}:`);
|
|
35
|
+
|
|
36
|
+
if (promise.stack) {
|
|
37
|
+
lines.push(formatPendingPromiseStack(promise.stack));
|
|
38
|
+
} else {
|
|
39
|
+
lines.push(' <stack unavailable>');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const formatHarnessErrorMessage = (
|
|
47
|
+
error: SerializedError | undefined,
|
|
48
|
+
options: {
|
|
49
|
+
testStartedAt?: number;
|
|
50
|
+
} = {},
|
|
51
|
+
): string | undefined => {
|
|
52
|
+
if (!error) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const parts = [error.message];
|
|
57
|
+
const pendingPromiseDetails = error.diagnostics?.pendingPromises
|
|
58
|
+
? formatPendingPromises(
|
|
59
|
+
error.diagnostics.pendingPromises,
|
|
60
|
+
options.testStartedAt,
|
|
61
|
+
)
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
if (pendingPromiseDetails) {
|
|
65
|
+
parts.push(pendingPromiseDetails);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (error.codeFrame) {
|
|
69
|
+
parts.push(error.codeFrame.content);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return parts.join('\n\n');
|
|
73
|
+
};
|