@react-native-harness/jest 1.1.0-rc.1 → 1.1.0-rc.3
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__/errors.test.js +6 -1
- package/dist/__tests__/harness-cache.test.d.ts +2 -0
- package/dist/__tests__/harness-cache.test.d.ts.map +1 -0
- package/dist/__tests__/harness-cache.test.js +51 -0
- package/dist/__tests__/harness.test.js +484 -47
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +42 -0
- package/dist/__tests__/resource-lock.test.d.ts +2 -0
- package/dist/__tests__/resource-lock.test.d.ts.map +1 -0
- package/dist/__tests__/resource-lock.test.js +103 -0
- package/dist/action-hooks.d.ts +9 -0
- package/dist/action-hooks.d.ts.map +1 -0
- package/dist/action-hooks.js +36 -0
- package/dist/cli-args.d.ts +1 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +5 -0
- package/dist/crash-supervisor.d.ts.map +1 -1
- package/dist/crash-supervisor.js +7 -6
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -2
- package/dist/harness.d.ts +23 -1
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +574 -136
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +205 -58
- package/dist/logs.d.ts +4 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +12 -0
- package/dist/resource-lock.d.ts +25 -0
- package/dist/resource-lock.d.ts.map +1 -0
- package/dist/resource-lock.js +278 -0
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +13 -8
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +29 -2
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/src/__tests__/errors.test.ts +18 -6
- package/src/__tests__/harness-cache.test.ts +74 -0
- package/src/__tests__/harness.test.ts +702 -49
- package/src/__tests__/index.test.ts +61 -0
- package/src/__tests__/resource-lock.test.ts +138 -0
- package/src/action-hooks.ts +66 -0
- package/src/cli-args.ts +6 -0
- package/src/crash-supervisor.ts +13 -6
- package/src/env.d.ts +1 -1
- package/src/errors.ts +15 -4
- package/src/harness.ts +743 -157
- package/src/index.ts +276 -64
- package/src/logs.ts +17 -3
- package/src/resource-lock.ts +450 -0
- package/src/run.ts +20 -10
- package/src/setup.ts +42 -1
- package/tsconfig.json +3 -3
- package/tsconfig.lib.json +6 -6
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { logger, type HarnessLogger } from '@react-native-harness/tools';
|
|
6
|
+
|
|
7
|
+
const resourceLockLogger = logger.child('resource-lock');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ROOT_DIR = path.join(os.tmpdir(), 'react-native-harness-locks');
|
|
10
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
11
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 2000;
|
|
12
|
+
const DEFAULT_STALE_LOCK_TIMEOUT_MS = 15000;
|
|
13
|
+
|
|
14
|
+
type ResourceLockMetadata = {
|
|
15
|
+
ticketId: string;
|
|
16
|
+
key: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
heartbeatAt: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ResourceLockAcquireOptions = {
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
onWait?: () => void;
|
|
25
|
+
onStillWaiting?: (elapsedMs: number) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ResourceLease = {
|
|
29
|
+
release: () => Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ResourceLockManager = {
|
|
33
|
+
acquire: (
|
|
34
|
+
key: string,
|
|
35
|
+
options?: ResourceLockAcquireOptions
|
|
36
|
+
) => Promise<ResourceLease>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type ResourceLockManagerOptions = {
|
|
40
|
+
rootDir?: string;
|
|
41
|
+
pollIntervalMs?: number;
|
|
42
|
+
heartbeatIntervalMs?: number;
|
|
43
|
+
staleLockTimeoutMs?: number;
|
|
44
|
+
pid?: number;
|
|
45
|
+
logger?: HarnessLogger;
|
|
46
|
+
isProcessActive?: (pid: number) => boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type LockPaths = {
|
|
50
|
+
rootDir: string;
|
|
51
|
+
keyDir: string;
|
|
52
|
+
queueDir: string;
|
|
53
|
+
ownerFilePath: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const wait = async (ms: number): Promise<void> => {
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const createAbortError = () =>
|
|
61
|
+
new DOMException('The operation was aborted', 'AbortError');
|
|
62
|
+
|
|
63
|
+
export const hashResourceLockKey = (key: string): string => {
|
|
64
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getPathsForKey = (rootDir: string, key: string): LockPaths => {
|
|
68
|
+
const keyDir = path.join(rootDir, hashResourceLockKey(key));
|
|
69
|
+
return {
|
|
70
|
+
rootDir,
|
|
71
|
+
keyDir,
|
|
72
|
+
queueDir: path.join(keyDir, 'queue'),
|
|
73
|
+
ownerFilePath: path.join(keyDir, 'owner.json'),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const createTicketId = (createdAt: number, pid: number): string => {
|
|
78
|
+
return `${createdAt
|
|
79
|
+
.toString()
|
|
80
|
+
.padStart(16, '0')}-${pid}-${crypto.randomUUID()}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const isMissingFileError = (error: unknown): boolean => {
|
|
84
|
+
return (
|
|
85
|
+
error instanceof Error &&
|
|
86
|
+
'code' in error &&
|
|
87
|
+
typeof error.code === 'string' &&
|
|
88
|
+
error.code === 'ENOENT'
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const isExclusiveCreateError = (error: unknown): boolean => {
|
|
93
|
+
return (
|
|
94
|
+
error instanceof Error &&
|
|
95
|
+
'code' in error &&
|
|
96
|
+
typeof error.code === 'string' &&
|
|
97
|
+
error.code === 'EEXIST'
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ensureLockDirectories = async (paths: LockPaths): Promise<void> => {
|
|
102
|
+
await fs.mkdir(paths.queueDir, { recursive: true });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const readJsonFile = async <T>(filePath: string): Promise<T | null> => {
|
|
106
|
+
try {
|
|
107
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
108
|
+
return JSON.parse(content) as T;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (isMissingFileError(error)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const removeFileIfPresent = async (filePath: string): Promise<void> => {
|
|
119
|
+
try {
|
|
120
|
+
await fs.rm(filePath, { force: true });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!isMissingFileError(error)) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const isPidActive = (pid: number): boolean => {
|
|
129
|
+
try {
|
|
130
|
+
process.kill(pid, 0);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return !(
|
|
134
|
+
error instanceof Error &&
|
|
135
|
+
'code' in error &&
|
|
136
|
+
typeof error.code === 'string' &&
|
|
137
|
+
error.code === 'ESRCH'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const isMetadataStale = (
|
|
143
|
+
metadata: ResourceLockMetadata,
|
|
144
|
+
now: number,
|
|
145
|
+
staleLockTimeoutMs: number,
|
|
146
|
+
isProcessActive: (pid: number) => boolean
|
|
147
|
+
): boolean => {
|
|
148
|
+
if (!isProcessActive(metadata.pid)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return now - metadata.heartbeatAt > staleLockTimeoutMs;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const isQueuedTicketStale = (
|
|
156
|
+
metadata: ResourceLockMetadata,
|
|
157
|
+
isProcessActive: (pid: number) => boolean
|
|
158
|
+
): boolean => {
|
|
159
|
+
return !isProcessActive(metadata.pid);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const waitForPollInterval = (
|
|
163
|
+
ms: number,
|
|
164
|
+
signal?: AbortSignal
|
|
165
|
+
): Promise<void> => {
|
|
166
|
+
if (!signal) {
|
|
167
|
+
return wait(ms);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (signal.aborted) {
|
|
171
|
+
return Promise.reject(signal.reason ?? createAbortError());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
signal.removeEventListener('abort', onAbort);
|
|
177
|
+
resolve();
|
|
178
|
+
}, ms);
|
|
179
|
+
const onAbort = () => {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
signal.removeEventListener('abort', onAbort);
|
|
182
|
+
reject(signal.reason ?? createAbortError());
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const readQueueTickets = async (
|
|
190
|
+
queueDir: string
|
|
191
|
+
): Promise<ResourceLockMetadata[]> => {
|
|
192
|
+
const ticketEntries = await fs.readdir(queueDir, { withFileTypes: true });
|
|
193
|
+
const tickets = await Promise.all(
|
|
194
|
+
ticketEntries
|
|
195
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
196
|
+
.map(async (entry) => ({
|
|
197
|
+
name: entry.name,
|
|
198
|
+
metadata: await readJsonFile<ResourceLockMetadata>(
|
|
199
|
+
path.join(queueDir, entry.name)
|
|
200
|
+
),
|
|
201
|
+
}))
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return tickets
|
|
205
|
+
.filter(
|
|
206
|
+
(entry): entry is { name: string; metadata: ResourceLockMetadata } =>
|
|
207
|
+
entry.metadata !== null
|
|
208
|
+
)
|
|
209
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
210
|
+
.map((entry) => entry.metadata);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const cleanupQueue = async (options: {
|
|
214
|
+
paths: LockPaths;
|
|
215
|
+
currentTicketId: string;
|
|
216
|
+
logger: HarnessLogger;
|
|
217
|
+
isProcessActive: (pid: number) => boolean;
|
|
218
|
+
}): Promise<ResourceLockMetadata[]> => {
|
|
219
|
+
const { paths, currentTicketId, logger, isProcessActive } = options;
|
|
220
|
+
const tickets = await readQueueTickets(paths.queueDir);
|
|
221
|
+
const activeTickets: ResourceLockMetadata[] = [];
|
|
222
|
+
|
|
223
|
+
for (const ticket of tickets) {
|
|
224
|
+
const isCurrentTicket = ticket.ticketId === currentTicketId;
|
|
225
|
+
const isStale =
|
|
226
|
+
!isCurrentTicket && isQueuedTicketStale(ticket, isProcessActive);
|
|
227
|
+
|
|
228
|
+
if (isStale) {
|
|
229
|
+
logger.debug(
|
|
230
|
+
'removing stale queued ticket %s for key %s',
|
|
231
|
+
ticket.ticketId,
|
|
232
|
+
ticket.key
|
|
233
|
+
);
|
|
234
|
+
await removeFileIfPresent(
|
|
235
|
+
path.join(paths.queueDir, `${ticket.ticketId}.json`)
|
|
236
|
+
);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
activeTickets.push(ticket);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return activeTickets;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const maybeClearStaleOwner = async (options: {
|
|
247
|
+
ownerFilePath: string;
|
|
248
|
+
staleLockTimeoutMs: number;
|
|
249
|
+
now: number;
|
|
250
|
+
logger: HarnessLogger;
|
|
251
|
+
isProcessActive: (pid: number) => boolean;
|
|
252
|
+
}): Promise<ResourceLockMetadata | null> => {
|
|
253
|
+
const { ownerFilePath, staleLockTimeoutMs, now, logger, isProcessActive } =
|
|
254
|
+
options;
|
|
255
|
+
const owner = await readJsonFile<ResourceLockMetadata>(ownerFilePath);
|
|
256
|
+
|
|
257
|
+
if (!owner) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!isMetadataStale(owner, now, staleLockTimeoutMs, isProcessActive)) {
|
|
262
|
+
return owner;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
logger.debug(
|
|
266
|
+
'removing stale owner ticket %s for key %s',
|
|
267
|
+
owner.ticketId,
|
|
268
|
+
owner.key
|
|
269
|
+
);
|
|
270
|
+
await removeFileIfPresent(ownerFilePath);
|
|
271
|
+
return null;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const claimOwnership = async (
|
|
275
|
+
ownerFilePath: string,
|
|
276
|
+
metadata: ResourceLockMetadata
|
|
277
|
+
): Promise<boolean> => {
|
|
278
|
+
try {
|
|
279
|
+
await fs.writeFile(ownerFilePath, JSON.stringify(metadata), {
|
|
280
|
+
encoding: 'utf8',
|
|
281
|
+
flag: 'wx',
|
|
282
|
+
});
|
|
283
|
+
return true;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isExclusiveCreateError(error)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export const createResourceLockManager = (
|
|
294
|
+
options: ResourceLockManagerOptions = {}
|
|
295
|
+
): ResourceLockManager => {
|
|
296
|
+
const rootDir = options.rootDir ?? DEFAULT_ROOT_DIR;
|
|
297
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
298
|
+
const heartbeatIntervalMs =
|
|
299
|
+
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
300
|
+
const staleLockTimeoutMs =
|
|
301
|
+
options.staleLockTimeoutMs ?? DEFAULT_STALE_LOCK_TIMEOUT_MS;
|
|
302
|
+
const pid = options.pid ?? process.pid;
|
|
303
|
+
const scopedLogger = options.logger ?? resourceLockLogger;
|
|
304
|
+
const isProcessActive = options.isProcessActive ?? isPidActive;
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
acquire: async (key, acquireOptions = {}) => {
|
|
308
|
+
const paths = getPathsForKey(rootDir, key);
|
|
309
|
+
await ensureLockDirectories(paths);
|
|
310
|
+
|
|
311
|
+
const createdAt = Date.now();
|
|
312
|
+
const ticketId = createTicketId(createdAt, pid);
|
|
313
|
+
const ticketPath = path.join(paths.queueDir, `${ticketId}.json`);
|
|
314
|
+
const metadata: ResourceLockMetadata = {
|
|
315
|
+
ticketId,
|
|
316
|
+
key,
|
|
317
|
+
pid,
|
|
318
|
+
createdAt,
|
|
319
|
+
heartbeatAt: createdAt,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
await fs.writeFile(ticketPath, JSON.stringify(metadata), 'utf8');
|
|
323
|
+
scopedLogger.debug('queued ticket %s for key %s', ticketId, key);
|
|
324
|
+
|
|
325
|
+
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
326
|
+
let released = false;
|
|
327
|
+
let didNotifyWait = false;
|
|
328
|
+
const waitStartedAt = Date.now();
|
|
329
|
+
|
|
330
|
+
const release = async () => {
|
|
331
|
+
if (released) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
released = true;
|
|
336
|
+
if (heartbeatTimer) {
|
|
337
|
+
clearInterval(heartbeatTimer);
|
|
338
|
+
heartbeatTimer = null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const owner = await readJsonFile<ResourceLockMetadata>(
|
|
342
|
+
paths.ownerFilePath
|
|
343
|
+
);
|
|
344
|
+
if (owner?.ticketId === ticketId) {
|
|
345
|
+
await removeFileIfPresent(paths.ownerFilePath);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await removeFileIfPresent(ticketPath);
|
|
349
|
+
scopedLogger.debug('released ticket %s for key %s', ticketId, key);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const startHeartbeat = () => {
|
|
353
|
+
heartbeatTimer = setInterval(async () => {
|
|
354
|
+
const nextHeartbeatAt = Date.now();
|
|
355
|
+
const owner = await readJsonFile<ResourceLockMetadata>(
|
|
356
|
+
paths.ownerFilePath
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (released || owner?.ticketId !== ticketId) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const nextMetadata: ResourceLockMetadata = {
|
|
364
|
+
...owner,
|
|
365
|
+
heartbeatAt: nextHeartbeatAt,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (released) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await fs.writeFile(
|
|
373
|
+
paths.ownerFilePath,
|
|
374
|
+
JSON.stringify(nextMetadata),
|
|
375
|
+
'utf8'
|
|
376
|
+
);
|
|
377
|
+
scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId);
|
|
378
|
+
}, heartbeatIntervalMs);
|
|
379
|
+
heartbeatTimer.unref?.();
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
while (true) {
|
|
384
|
+
acquireOptions.signal?.throwIfAborted();
|
|
385
|
+
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
const activeTickets = await cleanupQueue({
|
|
388
|
+
paths,
|
|
389
|
+
currentTicketId: ticketId,
|
|
390
|
+
logger: scopedLogger,
|
|
391
|
+
isProcessActive,
|
|
392
|
+
});
|
|
393
|
+
const ownIndex = activeTickets.findIndex(
|
|
394
|
+
(entry) => entry.ticketId === ticketId
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
if (ownIndex === -1) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Queued ticket ${ticketId} disappeared before acquisition.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const owner = await maybeClearStaleOwner({
|
|
404
|
+
ownerFilePath: paths.ownerFilePath,
|
|
405
|
+
staleLockTimeoutMs,
|
|
406
|
+
now,
|
|
407
|
+
logger: scopedLogger,
|
|
408
|
+
isProcessActive,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
if (ownIndex === 0 && owner === null) {
|
|
412
|
+
const claimed = await claimOwnership(paths.ownerFilePath, {
|
|
413
|
+
...metadata,
|
|
414
|
+
heartbeatAt: Date.now(),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (claimed) {
|
|
418
|
+
await removeFileIfPresent(ticketPath);
|
|
419
|
+
startHeartbeat();
|
|
420
|
+
scopedLogger.debug(
|
|
421
|
+
'acquired lock for key %s with ticket %s',
|
|
422
|
+
key,
|
|
423
|
+
ticketId
|
|
424
|
+
);
|
|
425
|
+
return { release };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!didNotifyWait) {
|
|
430
|
+
didNotifyWait = true;
|
|
431
|
+
acquireOptions.onWait?.();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
acquireOptions.onStillWaiting?.(Date.now() - waitStartedAt);
|
|
435
|
+
scopedLogger.debug(
|
|
436
|
+
'waiting for key %s with ticket %s at queue position %d',
|
|
437
|
+
key,
|
|
438
|
+
ticketId,
|
|
439
|
+
ownIndex + 1
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
await waitForPollInterval(pollIntervalMs, acquireOptions.signal);
|
|
443
|
+
}
|
|
444
|
+
} catch (error) {
|
|
445
|
+
await release();
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
};
|
package/src/run.ts
CHANGED
|
@@ -65,7 +65,12 @@ export type RunHarnessTestFile = (options: {
|
|
|
65
65
|
harness: Harness;
|
|
66
66
|
projectConfig: JestConfig.ProjectConfig;
|
|
67
67
|
globalConfig: JestConfig.GlobalConfig;
|
|
68
|
-
}) => Promise<
|
|
68
|
+
}) => Promise<{
|
|
69
|
+
jestResult: JestTestResult;
|
|
70
|
+
harnessResult: HarnessTestSuiteResult;
|
|
71
|
+
relativeTestPath: string;
|
|
72
|
+
duration: number;
|
|
73
|
+
}>;
|
|
69
74
|
|
|
70
75
|
export const runHarnessTestFile: RunHarnessTestFile = async ({
|
|
71
76
|
testPath,
|
|
@@ -84,7 +89,7 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
|
|
|
84
89
|
(setupFile) => path.relative(globalConfig.rootDir, setupFile)
|
|
85
90
|
);
|
|
86
91
|
|
|
87
|
-
const
|
|
92
|
+
const harnessResult = await harness.runTests(relativeTestPath, {
|
|
88
93
|
testNamePattern: globalConfig.testNamePattern,
|
|
89
94
|
setupFiles,
|
|
90
95
|
setupFilesAfterEnv,
|
|
@@ -92,7 +97,7 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
|
|
|
92
97
|
});
|
|
93
98
|
const end = Date.now();
|
|
94
99
|
|
|
95
|
-
const allTests = flattenTests(
|
|
100
|
+
const allTests = flattenTests(harnessResult);
|
|
96
101
|
const stats = calculateStats(allTests);
|
|
97
102
|
|
|
98
103
|
// Convert TestResult[] to the format expected by toTestResult
|
|
@@ -115,12 +120,12 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
|
|
|
115
120
|
});
|
|
116
121
|
|
|
117
122
|
// Check if the entire suite was skipped
|
|
118
|
-
const skipped =
|
|
123
|
+
const skipped = harnessResult.status === 'skipped';
|
|
119
124
|
|
|
120
125
|
// Get error message from suite if it failed
|
|
121
|
-
const errorMessage =
|
|
126
|
+
const errorMessage = harnessResult.error?.message || null;
|
|
122
127
|
|
|
123
|
-
const
|
|
128
|
+
const jestResult = toTestResult({
|
|
124
129
|
stats: {
|
|
125
130
|
failures: stats.failures,
|
|
126
131
|
pending: stats.pending,
|
|
@@ -133,15 +138,20 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
|
|
|
133
138
|
errorMessage,
|
|
134
139
|
tests,
|
|
135
140
|
jestTestPath: testPath,
|
|
136
|
-
coverage:
|
|
141
|
+
coverage: harnessResult.coverage as JestTestResult['coverage'],
|
|
137
142
|
});
|
|
138
143
|
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
jestResult.failureMessage = formatResultsErrors(
|
|
145
|
+
jestResult.testResults,
|
|
141
146
|
projectConfig,
|
|
142
147
|
globalConfig,
|
|
143
148
|
testPath
|
|
144
149
|
);
|
|
145
150
|
|
|
146
|
-
return
|
|
151
|
+
return {
|
|
152
|
+
jestResult,
|
|
153
|
+
harnessResult,
|
|
154
|
+
relativeTestPath,
|
|
155
|
+
duration: end - start,
|
|
156
|
+
};
|
|
147
157
|
};
|
package/src/setup.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getConfig,
|
|
3
3
|
type Config as HarnessConfig,
|
|
4
|
+
ConfigSchema,
|
|
4
5
|
} from '@react-native-harness/config';
|
|
5
6
|
import type { Config as JestConfig } from 'jest-runner';
|
|
6
7
|
import { getHarness } from './harness.js';
|
|
@@ -9,12 +10,18 @@ import { getAdditionalCliArgs, HarnessCliArgs } from './cli-args.js';
|
|
|
9
10
|
import { logTestEnvironmentReady, logTestRunHeader } from './logs.js';
|
|
10
11
|
import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
|
|
11
12
|
import { HarnessPlatform } from '@react-native-harness/platforms';
|
|
13
|
+
import { logger } from '@react-native-harness/tools';
|
|
14
|
+
import { createActionHooksPlugin } from './action-hooks.js';
|
|
15
|
+
|
|
16
|
+
const setupLogger = logger.child('setup');
|
|
12
17
|
|
|
13
18
|
const getHarnessConfig = async (
|
|
14
19
|
globalConfig: JestConfig.GlobalConfig
|
|
15
20
|
): Promise<HarnessConfig> => {
|
|
16
21
|
const projectRoot = globalConfig.rootDir;
|
|
22
|
+
setupLogger.debug('loading Harness config from %s', projectRoot);
|
|
17
23
|
const { config: harnessConfig } = await getConfig(projectRoot);
|
|
24
|
+
setupLogger.debug('loaded Harness config');
|
|
18
25
|
return harnessConfig;
|
|
19
26
|
};
|
|
20
27
|
|
|
@@ -36,12 +43,13 @@ const getHarnessRunner = (
|
|
|
36
43
|
throw new RunnerNotFoundError(selectedRunnerName);
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
setupLogger.debug('selected runner %s (%s)', runner.name, runner.platformId);
|
|
39
47
|
return runner;
|
|
40
48
|
};
|
|
41
49
|
|
|
42
50
|
export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
|
|
43
51
|
preRunMessage.remove(process.stderr);
|
|
44
|
-
|
|
52
|
+
let harnessConfig =
|
|
45
53
|
global.HARNESS_CONFIG ?? (await getHarnessConfig(globalConfig));
|
|
46
54
|
|
|
47
55
|
if (global.HARNESS) {
|
|
@@ -57,8 +65,36 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
|
|
|
57
65
|
});
|
|
58
66
|
|
|
59
67
|
const cliArgs = getAdditionalCliArgs();
|
|
68
|
+
|
|
69
|
+
if (cliArgs.metroPort != null) {
|
|
70
|
+
setupLogger.debug(
|
|
71
|
+
'applying CLI metro port override: %d',
|
|
72
|
+
cliArgs.metroPort
|
|
73
|
+
);
|
|
74
|
+
harnessConfig = ConfigSchema.parse({
|
|
75
|
+
...harnessConfig,
|
|
76
|
+
metroPort: cliArgs.metroPort,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (process.env.PRE_RUN_HOOK || process.env.AFTER_RUN_HOOK) {
|
|
81
|
+
harnessConfig = ConfigSchema.parse({
|
|
82
|
+
...harnessConfig,
|
|
83
|
+
plugins: [...(harnessConfig.plugins ?? []), createActionHooksPlugin()],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
const selectedRunner = getHarnessRunner(harnessConfig, cliArgs);
|
|
61
88
|
|
|
89
|
+
if (
|
|
90
|
+
harnessConfig.webSocketPort != null &&
|
|
91
|
+
harnessConfig.webSocketPort !== harnessConfig.metroPort
|
|
92
|
+
) {
|
|
93
|
+
logger.warn(
|
|
94
|
+
`Config option "webSocketPort" is deprecated and ignored. Harness now uses metroPort (${harnessConfig.metroPort}) for bridge traffic.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
if (globalConfig.collectCoverage) {
|
|
63
99
|
// This is going to be used by @react-native-harness/babel-preset
|
|
64
100
|
// to enable instrumentation of test files.
|
|
@@ -67,19 +103,24 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
|
|
|
67
103
|
if (harnessConfig.coverage?.root) {
|
|
68
104
|
process.env.RN_HARNESS_COVERAGE_ROOT = harnessConfig.coverage.root;
|
|
69
105
|
}
|
|
106
|
+
|
|
107
|
+
setupLogger.debug('coverage enabled for this run');
|
|
70
108
|
}
|
|
71
109
|
|
|
72
110
|
if (harnessConfig.disableViewFlattening) {
|
|
73
111
|
process.env.RN_HARNESS_VIEW_FLATTENING = 'false';
|
|
112
|
+
setupLogger.debug('view flattening disabled for runtime');
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
logTestRunHeader(selectedRunner);
|
|
116
|
+
setupLogger.debug('creating Harness instance');
|
|
77
117
|
const harness = await getHarness(
|
|
78
118
|
harnessConfig,
|
|
79
119
|
selectedRunner,
|
|
80
120
|
globalConfig.rootDir
|
|
81
121
|
);
|
|
82
122
|
logTestEnvironmentReady(selectedRunner);
|
|
123
|
+
setupLogger.debug('Harness instance is ready');
|
|
83
124
|
|
|
84
125
|
global.HARNESS_CONFIG = harnessConfig;
|
|
85
126
|
global.HARNESS = harness;
|
package/tsconfig.json
CHANGED
package/tsconfig.lib.json
CHANGED
|
@@ -8,24 +8,24 @@
|
|
|
8
8
|
"emitDeclarationOnly": false,
|
|
9
9
|
"forceConsistentCasingInFileNames": true,
|
|
10
10
|
"types": ["node"],
|
|
11
|
-
"lib": ["DOM"]
|
|
11
|
+
"lib": ["DOM", "ES2022"]
|
|
12
12
|
},
|
|
13
13
|
"include": ["src/**/*.ts"],
|
|
14
14
|
"references": [
|
|
15
15
|
{
|
|
16
|
-
"path": "../
|
|
16
|
+
"path": "../tools/tsconfig.lib.json"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
"path": "../
|
|
19
|
+
"path": "../platforms/tsconfig.lib.json"
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
"path": "../
|
|
22
|
+
"path": "../plugins/tsconfig.lib.json"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
"path": "../
|
|
25
|
+
"path": "../config/tsconfig.lib.json"
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
|
-
"path": "../
|
|
28
|
+
"path": "../bundler-metro/tsconfig.lib.json"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"path": "../bridge/tsconfig.lib.json"
|