@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.
Files changed (75) hide show
  1. package/dist/__tests__/bridge.test.js +6 -2
  2. package/dist/__tests__/crash-monitor.test.js +64 -0
  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__/execute-run.test.js +250 -4
  7. package/dist/__tests__/format-harness-error.test.d.ts +2 -0
  8. package/dist/__tests__/format-harness-error.test.d.ts.map +1 -0
  9. package/dist/__tests__/format-harness-error.test.js +27 -0
  10. package/dist/__tests__/harness-cache.test.js +62 -41
  11. package/dist/__tests__/harness-session-signal.test.d.ts +2 -0
  12. package/dist/__tests__/harness-session-signal.test.d.ts.map +1 -0
  13. package/dist/__tests__/harness-session-signal.test.js +298 -0
  14. package/dist/__tests__/harness-session.test.js +161 -2
  15. package/dist/__tests__/index.test.js +3 -1
  16. package/dist/__tests__/metro-port.test.js +2 -0
  17. package/dist/__tests__/run.test.d.ts +2 -0
  18. package/dist/__tests__/run.test.d.ts.map +1 -0
  19. package/dist/__tests__/run.test.js +48 -0
  20. package/dist/crash-monitor.d.ts +7 -0
  21. package/dist/crash-monitor.d.ts.map +1 -1
  22. package/dist/crash-monitor.js +19 -0
  23. package/dist/diagnostics/__tests__/derivers.test.d.ts +2 -0
  24. package/dist/diagnostics/__tests__/derivers.test.d.ts.map +1 -0
  25. package/dist/diagnostics/__tests__/derivers.test.js +248 -0
  26. package/dist/diagnostics/__tests__/report.test.d.ts +2 -0
  27. package/dist/diagnostics/__tests__/report.test.d.ts.map +1 -0
  28. package/dist/diagnostics/__tests__/report.test.js +97 -0
  29. package/dist/diagnostics/derivers.d.ts +24 -0
  30. package/dist/diagnostics/derivers.d.ts.map +1 -0
  31. package/dist/diagnostics/derivers.js +146 -0
  32. package/dist/diagnostics/index.d.ts +3 -0
  33. package/dist/diagnostics/index.d.ts.map +1 -0
  34. package/dist/diagnostics/index.js +2 -0
  35. package/dist/diagnostics/report.d.ts +7 -0
  36. package/dist/diagnostics/report.d.ts.map +1 -0
  37. package/dist/diagnostics/report.js +84 -0
  38. package/dist/environment-reset.d.ts +51 -0
  39. package/dist/environment-reset.d.ts.map +1 -0
  40. package/dist/environment-reset.js +83 -0
  41. package/dist/execute-run.d.ts.map +1 -1
  42. package/dist/execute-run.js +121 -69
  43. package/dist/format-harness-error.d.ts +5 -0
  44. package/dist/format-harness-error.d.ts.map +1 -0
  45. package/dist/format-harness-error.js +43 -0
  46. package/dist/harness-session.d.ts +26 -1
  47. package/dist/harness-session.d.ts.map +1 -1
  48. package/dist/harness-session.js +260 -87
  49. package/dist/run.d.ts.map +1 -1
  50. package/dist/run.js +21 -4
  51. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  52. package/package.json +8 -7
  53. package/src/__tests__/bridge.test.ts +8 -3
  54. package/src/__tests__/crash-monitor.test.ts +84 -0
  55. package/src/__tests__/environment-reset.test.ts +308 -0
  56. package/src/__tests__/execute-run.test.ts +327 -4
  57. package/src/__tests__/format-harness-error.test.ts +32 -0
  58. package/src/__tests__/harness-cache.test.ts +91 -0
  59. package/src/__tests__/harness-session.test.ts +211 -1
  60. package/src/__tests__/index.test.ts +3 -1
  61. package/src/__tests__/metro-port.test.ts +2 -0
  62. package/src/__tests__/run.test.ts +74 -0
  63. package/src/crash-monitor.ts +28 -0
  64. package/src/diagnostics/__tests__/derivers.test.ts +300 -0
  65. package/src/diagnostics/__tests__/report.test.ts +128 -0
  66. package/src/diagnostics/derivers.ts +170 -0
  67. package/src/diagnostics/index.ts +2 -0
  68. package/src/diagnostics/report.ts +124 -0
  69. package/src/environment-reset.ts +138 -0
  70. package/src/execute-run.ts +136 -77
  71. package/src/format-harness-error.ts +73 -0
  72. package/src/harness-session.ts +369 -131
  73. package/src/run.ts +23 -4
  74. package/tsconfig.json +3 -0
  75. package/tsconfig.lib.json +3 -0
@@ -28,7 +28,10 @@ afterEach(async () => {
28
28
  // Allow the server to close cleanly.
29
29
  await new Promise((r) => setTimeout(r, 10));
30
30
  });
31
- const connect = (callbacks = { runTests: vi.fn() }) => connectToHarness(`ws://127.0.0.1:${bridgePort}`, callbacks);
31
+ const connect = (callbacks = {
32
+ runTests: vi.fn(),
33
+ resetEnvironment: vi.fn(),
34
+ }) => connectToHarness(`ws://127.0.0.1:${bridgePort}`, callbacks);
32
35
  const device = {
33
36
  platform: 'ios',
34
37
  manufacturer: 'Apple',
@@ -107,7 +110,7 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
107
110
  };
108
111
  const runTestsCb = vi.fn(async () => suiteResult);
109
112
  const connectionPromise = bridge.nextConnection();
110
- const handle = await connect({ runTests: runTestsCb });
113
+ const handle = await connect({ runTests: runTestsCb, resetEnvironment: vi.fn() });
111
114
  handle.reportReady(device);
112
115
  const conn = await connectionPromise;
113
116
  const result = await conn.runTests('example.ts', {
@@ -120,6 +123,7 @@ describe('bridge: createHarnessBridge + connectToHarness', () => {
120
123
  it('rejects pending app calls when a newer client replaces the session', async () => {
121
124
  const firstHandle = await connect({
122
125
  runTests: async () => await new Promise(() => undefined),
126
+ resetEnvironment: vi.fn(),
123
127
  });
124
128
  firstHandle.reportReady(device);
125
129
  const firstConnection = await bridge.nextConnection();
@@ -163,4 +163,68 @@ describe('createCrashMonitor', () => {
163
163
  expect(session.removeListener).toHaveBeenCalledOnce();
164
164
  expect(cm.isAlive()).toBe(false);
165
165
  });
166
+ describe('expectDisconnect', () => {
167
+ it('suppresses handleBridgeDisconnect while the window is open', async () => {
168
+ const { session } = createAppSessionMock({ status: 'running' });
169
+ const cm = createCrashMonitor({ appSession: session });
170
+ const watch = cm.watch('test.ts', 'execution');
171
+ watch.promise.catch(noop);
172
+ const closeWindow = cm.expectDisconnect();
173
+ cm.handleBridgeDisconnect();
174
+ await waitForClassification();
175
+ const settled = vi.fn();
176
+ watch.promise.then(settled, settled);
177
+ await Promise.resolve();
178
+ expect(settled).not.toHaveBeenCalled();
179
+ closeWindow();
180
+ watch.cancel();
181
+ });
182
+ it('resumes classifying disconnects once the window closes', async () => {
183
+ const { session } = createAppSessionMock({ status: 'running' });
184
+ const cm = createCrashMonitor({ appSession: session });
185
+ const watch = cm.watch('test.ts', 'execution');
186
+ watch.promise.catch(noop);
187
+ const closeWindow = cm.expectDisconnect();
188
+ closeWindow();
189
+ cm.handleBridgeDisconnect();
190
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
191
+ });
192
+ it('keeps the window open until every opener has closed it (nested windows)', async () => {
193
+ const { session } = createAppSessionMock({ status: 'running' });
194
+ const cm = createCrashMonitor({ appSession: session });
195
+ const watch = cm.watch('test.ts', 'execution');
196
+ watch.promise.catch(noop);
197
+ const closeFirst = cm.expectDisconnect();
198
+ const closeSecond = cm.expectDisconnect();
199
+ closeFirst();
200
+ cm.handleBridgeDisconnect();
201
+ await waitForClassification();
202
+ const settled = vi.fn();
203
+ watch.promise.then(settled, settled);
204
+ await Promise.resolve();
205
+ expect(settled).not.toHaveBeenCalled();
206
+ closeSecond();
207
+ watch.cancel();
208
+ });
209
+ it('ignores a redundant close() call', async () => {
210
+ const { session } = createAppSessionMock({ status: 'running' });
211
+ const cm = createCrashMonitor({ appSession: session });
212
+ const watch = cm.watch('test.ts', 'execution');
213
+ watch.promise.catch(noop);
214
+ const closeWindow = cm.expectDisconnect();
215
+ closeWindow();
216
+ closeWindow();
217
+ cm.handleBridgeDisconnect();
218
+ await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError);
219
+ });
220
+ it('clears the expected-disconnect window on dispose', async () => {
221
+ const { session } = createAppSessionMock({ status: 'running' });
222
+ const cm = createCrashMonitor({ appSession: session });
223
+ cm.expectDisconnect();
224
+ await cm.dispose();
225
+ // A disconnect after dispose should not throw or hang; monitoring is
226
+ // fully torn down regardless of the window state.
227
+ expect(() => cm.handleBridgeDisconnect()).not.toThrow();
228
+ });
229
+ });
166
230
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=environment-reset.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environment-reset.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/environment-reset.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createProcessResetStrategy, createRuntimeResetStrategy, resolveResetStrategyKind, withEscalation, } from '../environment-reset.js';
3
+ describe('resolveResetStrategyKind', () => {
4
+ it('normalizes true to the process strategy', () => {
5
+ expect(resolveResetStrategyKind(true)).toBe('process');
6
+ });
7
+ it('normalizes false to null (no reset)', () => {
8
+ expect(resolveResetStrategyKind(false)).toBeNull();
9
+ });
10
+ it('passes through the process string value', () => {
11
+ expect(resolveResetStrategyKind('process')).toBe('process');
12
+ });
13
+ it('passes through the runtime string value', () => {
14
+ expect(resolveResetStrategyKind('runtime')).toBe('runtime');
15
+ });
16
+ });
17
+ describe('createProcessResetStrategy', () => {
18
+ it('has kind "process" and delegates to the injected restart function', async () => {
19
+ const restart = vi.fn(async () => undefined);
20
+ const strategy = createProcessResetStrategy({ restart });
21
+ expect(strategy.kind).toBe('process');
22
+ await strategy.reset({ testFilePath: '/tests/example.harness.ts' });
23
+ expect(restart).toHaveBeenCalledWith('/tests/example.harness.ts');
24
+ });
25
+ it('propagates restart failures', async () => {
26
+ const restart = vi.fn(async () => {
27
+ throw new Error('restart failed');
28
+ });
29
+ const strategy = createProcessResetStrategy({ restart });
30
+ await expect(strategy.reset({ testFilePath: '/tests/example.harness.ts' })).rejects.toThrow('restart failed');
31
+ });
32
+ });
33
+ describe('createRuntimeResetStrategy', () => {
34
+ const testFilePath = '/tests/example.harness.ts';
35
+ it('has kind "runtime" and resets via the connection then waits for reconnect', async () => {
36
+ const callOrder = [];
37
+ const closeWindow = vi.fn();
38
+ const connection = {
39
+ resetEnvironment: vi.fn(async () => {
40
+ callOrder.push('resetEnvironment');
41
+ }),
42
+ };
43
+ const waitForReconnect = vi.fn(async () => {
44
+ callOrder.push('waitForReconnect-started');
45
+ });
46
+ const strategy = createRuntimeResetStrategy({
47
+ getConnection: () => connection,
48
+ expectDisconnect: () => {
49
+ callOrder.push('expectDisconnect');
50
+ return closeWindow;
51
+ },
52
+ waitForReconnect,
53
+ isDisconnectError: () => false,
54
+ timeoutMs: 1000,
55
+ });
56
+ expect(strategy.kind).toBe('runtime');
57
+ await strategy.reset({ testFilePath });
58
+ expect(callOrder).toEqual([
59
+ 'expectDisconnect',
60
+ 'waitForReconnect-started',
61
+ 'resetEnvironment',
62
+ ]);
63
+ expect(waitForReconnect).toHaveBeenCalledWith(expect.any(AbortSignal));
64
+ expect(closeWindow).toHaveBeenCalledOnce();
65
+ });
66
+ it('starts waiting for reconnect before firing the RPC', async () => {
67
+ const callOrder = [];
68
+ const connection = {
69
+ resetEnvironment: vi.fn(async () => {
70
+ callOrder.push('resetEnvironment');
71
+ }),
72
+ };
73
+ const waitForReconnect = vi.fn(() => {
74
+ callOrder.push('waitForReconnect-called');
75
+ return Promise.resolve();
76
+ });
77
+ const strategy = createRuntimeResetStrategy({
78
+ getConnection: () => connection,
79
+ expectDisconnect: () => () => undefined,
80
+ waitForReconnect,
81
+ isDisconnectError: () => false,
82
+ timeoutMs: 1000,
83
+ });
84
+ await strategy.reset({ testFilePath });
85
+ expect(callOrder).toEqual(['waitForReconnect-called', 'resetEnvironment']);
86
+ });
87
+ it('throws when there is no active app connection', async () => {
88
+ const expectDisconnect = vi.fn();
89
+ const strategy = createRuntimeResetStrategy({
90
+ getConnection: () => null,
91
+ expectDisconnect,
92
+ waitForReconnect: vi.fn(async () => undefined),
93
+ isDisconnectError: () => false,
94
+ timeoutMs: 1000,
95
+ });
96
+ await expect(strategy.reset({ testFilePath })).rejects.toThrow('No active app connection');
97
+ expect(expectDisconnect).not.toHaveBeenCalled();
98
+ });
99
+ it('closes the expected-disconnect window even when the RPC call fails', async () => {
100
+ const closeWindow = vi.fn();
101
+ const connection = {
102
+ resetEnvironment: vi.fn(async () => {
103
+ throw new Error('rpc failed');
104
+ }),
105
+ };
106
+ const strategy = createRuntimeResetStrategy({
107
+ getConnection: () => connection,
108
+ expectDisconnect: () => closeWindow,
109
+ waitForReconnect: vi.fn(async () => undefined),
110
+ isDisconnectError: () => false,
111
+ timeoutMs: 1000,
112
+ });
113
+ await expect(strategy.reset({ testFilePath })).rejects.toThrow('rpc failed');
114
+ expect(closeWindow).toHaveBeenCalledOnce();
115
+ });
116
+ it('closes the expected-disconnect window even when reconnect times out', async () => {
117
+ const closeWindow = vi.fn();
118
+ const connection = { resetEnvironment: vi.fn(async () => undefined) };
119
+ const strategy = createRuntimeResetStrategy({
120
+ getConnection: () => connection,
121
+ expectDisconnect: () => closeWindow,
122
+ waitForReconnect: vi.fn(async () => {
123
+ throw new Error('reconnect timed out');
124
+ }),
125
+ isDisconnectError: () => false,
126
+ timeoutMs: 1000,
127
+ });
128
+ await expect(strategy.reset({ testFilePath })).rejects.toThrow('reconnect timed out');
129
+ expect(closeWindow).toHaveBeenCalledOnce();
130
+ });
131
+ it('treats a disconnect-flavored RPC rejection as reload-in-flight and succeeds', async () => {
132
+ const disconnectError = new Error('app bridge disconnected');
133
+ const connection = {
134
+ resetEnvironment: vi.fn(async () => {
135
+ throw disconnectError;
136
+ }),
137
+ };
138
+ const waitForReconnect = vi.fn(async () => undefined);
139
+ const strategy = createRuntimeResetStrategy({
140
+ getConnection: () => connection,
141
+ expectDisconnect: () => () => undefined,
142
+ waitForReconnect,
143
+ isDisconnectError: (error) => error === disconnectError,
144
+ timeoutMs: 1000,
145
+ });
146
+ await expect(strategy.reset({ testFilePath })).resolves.toBeUndefined();
147
+ expect(waitForReconnect).toHaveBeenCalledOnce();
148
+ });
149
+ it('still throws on a non-disconnect RPC rejection', async () => {
150
+ const connection = {
151
+ resetEnvironment: vi.fn(async () => {
152
+ throw new Error('rpc failed');
153
+ }),
154
+ };
155
+ const strategy = createRuntimeResetStrategy({
156
+ getConnection: () => connection,
157
+ expectDisconnect: () => () => undefined,
158
+ waitForReconnect: vi.fn(async () => undefined),
159
+ isDisconnectError: (error) => error instanceof Error && error.message === 'app bridge disconnected',
160
+ timeoutMs: 1000,
161
+ });
162
+ await expect(strategy.reset({ testFilePath })).rejects.toThrow('rpc failed');
163
+ });
164
+ it('does not leave an unhandled rejection when the RPC fails before the reconnect wait settles', async () => {
165
+ let rejectReconnect;
166
+ const waitForReconnect = vi.fn(() => new Promise((_, reject) => {
167
+ rejectReconnect = reject;
168
+ }));
169
+ const connection = {
170
+ resetEnvironment: vi.fn(async () => {
171
+ throw new Error('rpc failed');
172
+ }),
173
+ };
174
+ const unhandled = [];
175
+ const onUnhandledRejection = (reason) => {
176
+ unhandled.push(reason);
177
+ };
178
+ process.on('unhandledRejection', onUnhandledRejection);
179
+ try {
180
+ const strategy = createRuntimeResetStrategy({
181
+ getConnection: () => connection,
182
+ expectDisconnect: () => () => undefined,
183
+ waitForReconnect,
184
+ isDisconnectError: () => false,
185
+ timeoutMs: 1000,
186
+ });
187
+ await expect(strategy.reset({ testFilePath })).rejects.toThrow('rpc failed');
188
+ // Simulate the reconnect timeout firing after the strategy already
189
+ // threw; the strategy must have attached a rejection handler.
190
+ rejectReconnect(new Error('reconnect timed out'));
191
+ await new Promise((resolve) => setImmediate(resolve));
192
+ expect(unhandled).toEqual([]);
193
+ }
194
+ finally {
195
+ process.off('unhandledRejection', onUnhandledRejection);
196
+ }
197
+ });
198
+ });
199
+ describe('withEscalation', () => {
200
+ const testFilePath = '/tests/example.harness.ts';
201
+ const makeStrategy = (kind, reset) => ({ kind, reset });
202
+ it('reports the primary strategy kind', () => {
203
+ const primary = makeStrategy('runtime', async () => undefined);
204
+ const fallback = makeStrategy('process', async () => undefined);
205
+ expect(withEscalation(primary, fallback).kind).toBe('runtime');
206
+ });
207
+ it('does not call the fallback when the primary succeeds', async () => {
208
+ const fallbackReset = vi.fn(async () => undefined);
209
+ const primary = makeStrategy('runtime', async () => undefined);
210
+ const fallback = makeStrategy('process', fallbackReset);
211
+ const onEscalate = vi.fn();
212
+ await withEscalation(primary, fallback, { onEscalate }).reset({ testFilePath });
213
+ expect(fallbackReset).not.toHaveBeenCalled();
214
+ expect(onEscalate).not.toHaveBeenCalled();
215
+ });
216
+ it('escalates to the fallback when the primary fails', async () => {
217
+ const primaryError = new Error('runtime reset failed');
218
+ const fallbackReset = vi.fn(async () => undefined);
219
+ const primary = makeStrategy('runtime', async () => {
220
+ throw primaryError;
221
+ });
222
+ const fallback = makeStrategy('process', fallbackReset);
223
+ const onEscalate = vi.fn();
224
+ await withEscalation(primary, fallback, { onEscalate }).reset({ testFilePath });
225
+ expect(fallbackReset).toHaveBeenCalledWith({ testFilePath });
226
+ expect(onEscalate).toHaveBeenCalledWith(primaryError);
227
+ });
228
+ it('propagates the fallback error when both strategies fail', async () => {
229
+ const fallbackError = new Error('process reset also failed');
230
+ const primary = makeStrategy('runtime', async () => {
231
+ throw new Error('runtime reset failed');
232
+ });
233
+ const fallback = makeStrategy('process', async () => {
234
+ throw fallbackError;
235
+ });
236
+ await expect(withEscalation(primary, fallback).reset({ testFilePath })).rejects.toBe(fallbackError);
237
+ });
238
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi, beforeEach } from 'vitest';
2
2
  import { NativeCrashError, StartupStallError } from '../errors.js';
3
3
  import { AppBridgeDisconnectedError, DeviceNotRespondingError, } from '@react-native-harness/bridge/server';
4
+ import { createDiagnostics } from '@react-native-harness/tools';
4
5
  import { executeRun } from '../execute-run.js';
5
6
  const resolveUndefined = async () => undefined;
6
7
  // Mock the file-runner so we control what jestResult/harnessResult each test returns
@@ -9,6 +10,15 @@ const mockRunHarnessTestFile = vi.hoisted(() => vi.fn());
9
10
  vi.mock('../run.js', () => ({
10
11
  runHarnessTestFile: mockRunHarnessTestFile,
11
12
  }));
13
+ // Mock the reporting module so diagnostics tests can inspect the entries that
14
+ // would have been reported without touching the real filesystem, and so
15
+ // asserting on them doesn't race with executeRun's own diagnostics.flush().
16
+ const mockPrintSummary = vi.hoisted(() => vi.fn());
17
+ const mockWriteTraceFile = vi.hoisted(() => vi.fn(async () => '/fake/trace.json'));
18
+ vi.mock('../diagnostics/index.js', () => ({
19
+ printSummary: mockPrintSummary,
20
+ writeTraceFile: mockWriteTraceFile,
21
+ }));
12
22
  // ---------------------------------------------------------------------------
13
23
  // Helpers
14
24
  // ---------------------------------------------------------------------------
@@ -80,9 +90,11 @@ const makeSession = (overrides = {}) => ({
80
90
  config: {},
81
91
  },
82
92
  },
93
+ diagnostics: createDiagnostics({ enabled: false }),
83
94
  ensureAppReady: vi.fn(resolveUndefined),
84
95
  runTestFile: vi.fn(async () => makeHarnessResult()),
85
96
  restartApp: vi.fn(resolveUndefined),
97
+ resetEnvironment: vi.fn(resolveUndefined),
86
98
  resetCrashState: vi.fn(),
87
99
  flushClientLogs: vi.fn(() => []),
88
100
  callHook: vi.fn(resolveUndefined),
@@ -138,13 +150,57 @@ describe('executeRun', () => {
138
150
  expect(hookNames).toContain('run:finished');
139
151
  });
140
152
  });
153
+ describe('diagnostics', () => {
154
+ beforeEach(() => {
155
+ mockPrintSummary.mockClear();
156
+ mockWriteTraceFile.mockClear();
157
+ });
158
+ it('records run.total and run.file spans when diagnostics is enabled', async () => {
159
+ const diagnostics = createDiagnostics({ enabled: true });
160
+ const session = makeSession({ diagnostics });
161
+ await executeRun(session, [makeTest('/a.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
162
+ // executeRun flushes and hands entries to the (mocked) reporter in its
163
+ // finally block, so we inspect what was reported rather than flushing
164
+ // the diagnostics instance ourselves.
165
+ expect(mockPrintSummary).toHaveBeenCalledTimes(1);
166
+ const entries = mockPrintSummary.mock.calls[0]?.[0];
167
+ const runEntry = entries.find((e) => e.label === 'run.total');
168
+ const fileEntry = entries.find((e) => e.label === 'run.file');
169
+ expect(runEntry).toMatchObject({ status: 'ok', attrs: { status: 'passed' } });
170
+ expect(runEntry?.attrs?.runId).toBeTypeOf('string');
171
+ expect(fileEntry).toMatchObject({ status: 'ok', attrs: { file: '../a.ts', status: 'passed' } });
172
+ expect(fileEntry?.attrs?.runId).toBeTypeOf('string');
173
+ expect(mockWriteTraceFile).toHaveBeenCalledWith(entries, expect.objectContaining({ runId: expect.any(String) }));
174
+ });
175
+ it('still records run.total when a test throws', async () => {
176
+ const diagnostics = createDiagnostics({ enabled: true });
177
+ const session = makeSession({
178
+ diagnostics,
179
+ ensureAppReady: vi.fn(async () => { throw new Error('unexpected'); }),
180
+ });
181
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
182
+ const entries = mockPrintSummary.mock.calls[0]?.[0];
183
+ expect(entries.find((e) => e.label === 'run.total')).toBeDefined();
184
+ expect(entries.find((e) => e.label === 'run.file')).toMatchObject({
185
+ attrs: { status: 'failed' },
186
+ });
187
+ });
188
+ it('is a no-op and does not generate a report when diagnostics is disabled', async () => {
189
+ const diagnostics = createDiagnostics({ enabled: false });
190
+ const session = makeSession({ diagnostics });
191
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
192
+ expect(diagnostics.flush()).toEqual([]);
193
+ expect(mockPrintSummary).not.toHaveBeenCalled();
194
+ expect(mockWriteTraceFile).not.toHaveBeenCalled();
195
+ });
196
+ });
141
197
  describe('happy path', () => {
142
198
  it('emits file start, ensureAppReady, runTestFile, file success in order', async () => {
143
199
  const order = [];
144
200
  const session = makeSession({
145
201
  ensureAppReady: vi.fn(async () => { order.push('ensureAppReady'); }),
146
202
  });
147
- const emitEvent = (async (eventName, ..._eventData) => {
203
+ const emitEvent = (async (eventName) => {
148
204
  if (eventName === 'test-file-start') {
149
205
  order.push('test-file-start');
150
206
  }
@@ -178,6 +234,14 @@ describe('executeRun', () => {
178
234
  expect(payload.summary.passed).toBe(6);
179
235
  expect(payload.status).toBe('passed');
180
236
  });
237
+ it('marks the final run state as completed', async () => {
238
+ const session = makeSession();
239
+ await executeRun(session, [makeTest()], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
240
+ expect(session.setRunState).toHaveBeenLastCalledWith(expect.objectContaining({
241
+ completed: true,
242
+ status: 'passed',
243
+ }));
244
+ });
181
245
  it('attaches buffered client logs to the Jest result', async () => {
182
246
  const clientLogs = [{ message: 'Loaded screen', origin: '', type: 'warn' }];
183
247
  const session = makeSession({
@@ -257,6 +321,56 @@ describe('executeRun', () => {
257
321
  ['test-file-success', expect.anything(), expect.anything()],
258
322
  ]);
259
323
  });
324
+ it('includes pending promise diagnostics in live test-case failures', async () => {
325
+ let testRunnerListener;
326
+ const { emitEvent, calls: emittedEvents } = makeEmitEvent();
327
+ const session = makeSession({
328
+ onTestRunnerEvent: vi.fn((listener) => {
329
+ testRunnerListener = listener;
330
+ return () => undefined;
331
+ }),
332
+ });
333
+ mockRunHarnessTestFile.mockImplementation(async () => {
334
+ testRunnerListener?.({
335
+ type: 'test-finished',
336
+ file: 'example.ts',
337
+ suite: 'suite',
338
+ name: 'hangs',
339
+ ancestorTitles: ['suite'],
340
+ fullName: 'suite hangs',
341
+ startedAt: 100,
342
+ duration: 50,
343
+ status: 'failed',
344
+ error: {
345
+ name: 'TestCaseTimeoutError',
346
+ message: 'Test timed out after 50ms: suite hangs',
347
+ diagnostics: {
348
+ pendingPromises: {
349
+ total: 1,
350
+ items: [
351
+ {
352
+ id: 7,
353
+ createdAt: 110,
354
+ stack: 'Error: Promise created\n at hangs (example.ts:10:5)',
355
+ },
356
+ ],
357
+ },
358
+ },
359
+ },
360
+ });
361
+ return makeFileRunResult();
362
+ });
363
+ await executeRun(session, [makeTest()], makeWatcher(), emitEvent, makeGlobalConfig());
364
+ expect(emittedEvents).toContainEqual([
365
+ 'test-case-result',
366
+ 'example.ts',
367
+ expect.objectContaining({
368
+ failureMessages: [
369
+ expect.stringContaining('Pending promises at timeout: 1\n\nPromise #7, created 10ms after test start:'),
370
+ ],
371
+ }),
372
+ ]);
373
+ });
260
374
  });
261
375
  describe('runtime failures', () => {
262
376
  it('passes StartupStallError to onFailure with an empty stack', async () => {
@@ -334,7 +448,7 @@ describe('executeRun', () => {
334
448
  });
335
449
  });
336
450
  describe('resetEnvironmentBetweenTestFiles', () => {
337
- it('calls restartApp before the second test but not the first', async () => {
451
+ it('calls resetEnvironment before the second test but not the first', async () => {
338
452
  const session = makeSession({
339
453
  config: {
340
454
  metroPort: 8081,
@@ -347,8 +461,139 @@ describe('executeRun', () => {
347
461
  },
348
462
  });
349
463
  await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts'), makeTest('/c.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
350
- // restartApp should be called for tests 2 and 3, not test 1.
351
- expect(session.restartApp).toHaveBeenCalledTimes(2);
464
+ // resetEnvironment should be called for tests 2 and 3, not test 1.
465
+ expect(session.resetEnvironment).toHaveBeenCalledTimes(2);
466
+ expect(session.restartApp).not.toHaveBeenCalled();
467
+ });
468
+ it('calls resetEnvironment with the "runtime" strategy configured', async () => {
469
+ const session = makeSession({
470
+ config: {
471
+ metroPort: 8081,
472
+ resetEnvironmentBetweenTestFiles: 'runtime',
473
+ detectNativeCrashes: false,
474
+ runners: [
475
+ { platformId: 'android', name: 'android' },
476
+ { platformId: 'ios', name: 'ios' },
477
+ ],
478
+ },
479
+ });
480
+ await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
481
+ expect(session.resetEnvironment).toHaveBeenCalledTimes(1);
482
+ expect(session.resetEnvironment).toHaveBeenCalledWith('/b.ts');
483
+ });
484
+ it('does not call resetEnvironment when resetEnvironmentBetweenTestFiles is false', async () => {
485
+ const session = makeSession({
486
+ config: {
487
+ metroPort: 8081,
488
+ resetEnvironmentBetweenTestFiles: false,
489
+ detectNativeCrashes: false,
490
+ runners: [
491
+ { platformId: 'android', name: 'android' },
492
+ { platformId: 'ios', name: 'ios' },
493
+ ],
494
+ },
495
+ });
496
+ await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
497
+ expect(session.resetEnvironment).not.toHaveBeenCalled();
498
+ });
499
+ it('restarts after a test case timeout before the next runnable file', async () => {
500
+ const timedOutResult = makeHarnessResult('failed');
501
+ timedOutResult.tests = [
502
+ {
503
+ name: 'hangs',
504
+ status: 'failed',
505
+ duration: 10,
506
+ error: {
507
+ name: 'TestCaseTimeoutError',
508
+ message: 'Test timed out after 10ms: hangs',
509
+ },
510
+ },
511
+ ];
512
+ mockRunHarnessTestFile
513
+ .mockResolvedValueOnce(makeFileRunResult({
514
+ harnessResult: timedOutResult,
515
+ jestResult: makeJestResult({
516
+ numFailingTests: 1,
517
+ numPassingTests: 0,
518
+ }),
519
+ }))
520
+ .mockResolvedValueOnce(makeFileRunResult());
521
+ const session = makeSession({
522
+ config: {
523
+ metroPort: 8081,
524
+ resetEnvironmentBetweenTestFiles: false,
525
+ detectNativeCrashes: false,
526
+ runners: [
527
+ { platformId: 'android', name: 'android' },
528
+ { platformId: 'ios', name: 'ios' },
529
+ ],
530
+ },
531
+ });
532
+ await executeRun(session, [makeTest('/a.ts'), makeTest('/b.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
533
+ expect(session.restartApp).toHaveBeenCalledTimes(1);
534
+ expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
535
+ });
536
+ it('restarts after a timeout in the last runnable file', async () => {
537
+ const timedOutResult = makeHarnessResult('failed');
538
+ timedOutResult.tests = [
539
+ {
540
+ name: 'hangs',
541
+ status: 'failed',
542
+ duration: 10,
543
+ error: {
544
+ name: 'TestCaseTimeoutError',
545
+ message: 'Test timed out after 10ms: hangs',
546
+ },
547
+ },
548
+ ];
549
+ mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({
550
+ harnessResult: timedOutResult,
551
+ jestResult: makeJestResult({
552
+ numFailingTests: 1,
553
+ numPassingTests: 0,
554
+ }),
555
+ }));
556
+ const session = makeSession({
557
+ config: {
558
+ metroPort: 8081,
559
+ resetEnvironmentBetweenTestFiles: false,
560
+ detectNativeCrashes: false,
561
+ runners: [
562
+ { platformId: 'android', name: 'android' },
563
+ { platformId: 'ios', name: 'ios' },
564
+ ],
565
+ },
566
+ });
567
+ await executeRun(session, [makeTest('/a.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig({ watch: true }));
568
+ expect(session.restartApp).toHaveBeenCalledTimes(1);
569
+ expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
570
+ });
571
+ it('restarts after a suite hook timeout', async () => {
572
+ const timedOutResult = makeHarnessResult('failed');
573
+ timedOutResult.suites = [
574
+ {
575
+ name: 'suite',
576
+ tests: [],
577
+ suites: [],
578
+ status: 'failed',
579
+ duration: 10,
580
+ error: {
581
+ name: 'SuiteHookTimeoutError',
582
+ message: 'beforeAll hook timed out after 10ms in suite: suite',
583
+ },
584
+ },
585
+ ];
586
+ mockRunHarnessTestFile.mockResolvedValueOnce(makeFileRunResult({
587
+ harnessResult: timedOutResult,
588
+ jestResult: makeJestResult({
589
+ numFailingTests: 1,
590
+ numPassingTests: 0,
591
+ }),
592
+ }));
593
+ const session = makeSession();
594
+ await executeRun(session, [makeTest('/a.ts')], makeWatcher(), makeEmitEvent().emitEvent, makeGlobalConfig());
595
+ expect(session.restartApp).toHaveBeenCalledTimes(1);
596
+ expect(session.restartApp).toHaveBeenCalledWith('/a.ts');
352
597
  });
353
598
  });
354
599
  describe('platform-specific test files', () => {
@@ -387,6 +632,7 @@ describe('executeRun', () => {
387
632
  makeTest('/project/kotlin.android.harness.ts'),
388
633
  ], makeWatcher(), vi.fn(), makeGlobalConfig());
389
634
  expect(session.restartApp).not.toHaveBeenCalled();
635
+ expect(session.resetEnvironment).not.toHaveBeenCalled();
390
636
  expect(mockRunHarnessTestFile).toHaveBeenCalledTimes(1);
391
637
  });
392
638
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=format-harness-error.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-harness-error.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/format-harness-error.test.ts"],"names":[],"mappings":""}