@react-native-harness/jest 1.3.0 → 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.
Files changed (98) hide show
  1. package/dist/__tests__/bridge.test.js +29 -4
  2. package/dist/__tests__/crash-monitor.test.js +194 -176
  3. package/dist/__tests__/environment-reset.test.d.ts +2 -0
  4. package/dist/__tests__/environment-reset.test.d.ts.map +1 -0
  5. package/dist/__tests__/environment-reset.test.js +238 -0
  6. package/dist/__tests__/errors.test.js +39 -0
  7. package/dist/__tests__/execute-run.test.js +439 -29
  8. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  9. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/format-harness-error.test.js +27 -0
  11. package/dist/__tests__/harness-cache.test.js +62 -41
  12. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  13. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  14. package/dist/__tests__/harness-session-signal.test.js +298 -0
  15. package/dist/__tests__/harness-session.test.js +217 -234
  16. package/dist/__tests__/index.test.js +62 -11
  17. package/dist/__tests__/metro-port.test.js +2 -0
  18. package/dist/__tests__/run.test.d.ts +2 -0
  19. package/dist/__tests__/run.test.d.ts.map +1 -0
  20. package/dist/__tests__/run.test.js +48 -0
  21. package/dist/__tests__/test-file-platform-filter.test.d.ts +2 -0
  22. package/dist/__tests__/test-file-platform-filter.test.d.ts.map +1 -0
  23. package/dist/__tests__/test-file-platform-filter.test.js +47 -0
  24. package/dist/crash-monitor.d.ts +12 -4
  25. package/dist/crash-monitor.d.ts.map +1 -1
  26. package/dist/crash-monitor.js +135 -102
  27. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  28. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  29. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  30. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  31. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  32. package/dist/diagnostics/__tests__/report.test.js +97 -0
  33. package/dist/diagnostics/derivers.d.ts +24 -0
  34. package/dist/diagnostics/derivers.d.ts.map +1 -0
  35. package/dist/diagnostics/derivers.js +146 -0
  36. package/dist/diagnostics/index.d.ts +3 -0
  37. package/dist/diagnostics/index.d.ts.map +1 -0
  38. package/dist/diagnostics/index.js +2 -0
  39. package/dist/diagnostics/report.d.ts +7 -0
  40. package/dist/diagnostics/report.d.ts.map +1 -0
  41. package/dist/diagnostics/report.js +84 -0
  42. package/dist/environment-reset.d.ts +51 -0
  43. package/dist/environment-reset.d.ts.map +1 -0
  44. package/dist/environment-reset.js +83 -0
  45. package/dist/errors.d.ts +10 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +41 -2
  48. package/dist/execute-run.d.ts +4 -2
  49. package/dist/execute-run.d.ts.map +1 -1
  50. package/dist/execute-run.js +179 -49
  51. package/dist/format-harness-error.d.ts +5 -0
  52. package/dist/format-harness-error.d.ts.map +1 -0
  53. package/dist/format-harness-error.js +43 -0
  54. package/dist/harness-session.d.ts +46 -1
  55. package/dist/harness-session.d.ts.map +1 -1
  56. package/dist/harness-session.js +386 -118
  57. package/dist/index.d.ts +7 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +24 -2
  60. package/dist/run.d.ts.map +1 -1
  61. package/dist/run.js +22 -4
  62. package/dist/test-file-platform-filter.d.ts +5 -0
  63. package/dist/test-file-platform-filter.d.ts.map +1 -0
  64. package/dist/test-file-platform-filter.js +40 -0
  65. package/dist/toTestResult.d.ts +1 -0
  66. package/dist/toTestResult.d.ts.map +1 -1
  67. package/dist/toTestResult.js +2 -2
  68. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  69. package/package.json +8 -7
  70. package/src/__tests__/bridge.test.ts +45 -5
  71. package/src/__tests__/crash-monitor.test.ts +234 -199
  72. package/src/__tests__/environment-reset.test.ts +308 -0
  73. package/src/__tests__/errors.test.ts +75 -0
  74. package/src/__tests__/execute-run.test.ts +561 -37
  75. package/src/__tests__/format-harness-error.test.ts +32 -0
  76. package/src/__tests__/harness-cache.test.ts +91 -0
  77. package/src/__tests__/harness-session.test.ts +303 -0
  78. package/src/__tests__/index.test.ts +92 -12
  79. package/src/__tests__/metro-port.test.ts +2 -0
  80. package/src/__tests__/run.test.ts +74 -0
  81. package/src/__tests__/test-file-platform-filter.test.ts +83 -0
  82. package/src/crash-monitor.ts +220 -131
  83. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  84. package/src/diagnostics/__tests__/report.test.ts +128 -0
  85. package/src/diagnostics/derivers.ts +170 -0
  86. package/src/diagnostics/index.ts +2 -0
  87. package/src/diagnostics/report.ts +124 -0
  88. package/src/environment-reset.ts +138 -0
  89. package/src/errors.ts +62 -1
  90. package/src/execute-run.ts +230 -57
  91. package/src/format-harness-error.ts +73 -0
  92. package/src/harness-session.ts +535 -162
  93. package/src/index.ts +40 -11
  94. package/src/run.ts +24 -4
  95. package/src/test-file-platform-filter.ts +57 -0
  96. package/src/toTestResult.ts +3 -2
  97. package/tsconfig.json +3 -0
  98. 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/errors.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HarnessError } from '@react-native-harness/tools';
1
+ import { formatPath, HarnessError } from '@react-native-harness/tools';
2
2
  import type { AppCrashDetails } from '@react-native-harness/platforms';
3
3
  export {
4
4
  StartupStallError,
@@ -55,6 +55,10 @@ export type NativeCrashDetails = AppCrashDetails & {
55
55
  phase: NativeCrashPhase;
56
56
  };
57
57
 
58
+ export type RuntimeDisconnectDetails = AppCrashDetails & {
59
+ phase: NativeCrashPhase;
60
+ };
61
+
58
62
  const buildNativeCrashMessage = ({
59
63
  phase,
60
64
  summary,
@@ -64,12 +68,28 @@ const buildNativeCrashMessage = ({
64
68
  pid,
65
69
  stackTrace,
66
70
  artifactType,
71
+ artifactPath,
72
+ enrichmentArtifacts,
67
73
  }: NativeCrashDetails) => {
68
74
  const lines = [
69
75
  phase === 'startup'
70
76
  ? 'The native app crashed while preparing to run this test file.'
71
77
  : 'The native app crashed during test execution.',
72
78
  ];
79
+
80
+ lines.push(
81
+ artifactPath
82
+ ? `Harness extracted the crash log: ${formatPath(artifactPath)}`
83
+ : "Harness couldn't extract the crash log."
84
+ );
85
+
86
+ if (enrichmentArtifacts && enrichmentArtifacts.length > 0) {
87
+ lines.push('Additional crash artifacts:');
88
+ for (const artifact of enrichmentArtifacts) {
89
+ lines.push(` - ${formatPath(artifact.artifactPath)}`);
90
+ }
91
+ }
92
+
73
93
  const hasCrashBlock = summary?.includes('\n') ?? false;
74
94
  const shouldRenderSummary =
75
95
  Boolean(summary) &&
@@ -119,3 +139,44 @@ export class NativeCrashError extends HarnessError {
119
139
  return this.details.phase;
120
140
  }
121
141
  }
142
+
143
+ const buildRuntimeDisconnectMessage = ({
144
+ phase,
145
+ summary,
146
+ rawLines,
147
+ }: RuntimeDisconnectDetails) => {
148
+ const lines = [
149
+ phase === 'startup'
150
+ ? 'The native runtime disconnected while preparing to run this test file.'
151
+ : 'The native runtime disconnected during test execution.',
152
+ ];
153
+
154
+ if (summary) {
155
+ lines.push('');
156
+ lines.push(summary);
157
+ }
158
+
159
+ if (rawLines && rawLines.length > 0 && summary !== rawLines.join('\n')) {
160
+ lines.push('');
161
+ lines.push(...rawLines);
162
+ }
163
+
164
+ return lines.join('\n');
165
+ };
166
+
167
+ export class RuntimeDisconnectError extends HarnessError {
168
+ constructor(
169
+ public readonly testFilePath: string,
170
+ public readonly details: RuntimeDisconnectDetails
171
+ ) {
172
+ super(buildRuntimeDisconnectMessage(details));
173
+ this.name = 'RuntimeDisconnectError';
174
+ this.stack = `${this.name}: ${this.message.split('\n')[0]}`;
175
+ }
176
+
177
+ get phase() {
178
+ return this.details.phase;
179
+ }
180
+ }
181
+
182
+ export type HarnessRuntimeFailure = NativeCrashError | RuntimeDisconnectError;