@react-native-harness/jest 1.1.0-rc.2 → 1.1.0-rc.4
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.js +1 -1
- package/dist/__tests__/harness.test.js +205 -3
- package/dist/__tests__/metro-port.test.d.ts +2 -0
- package/dist/__tests__/metro-port.test.d.ts.map +1 -0
- package/dist/__tests__/metro-port.test.js +52 -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 +138 -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.test.d.ts +2 -0
- package/dist/cli-args.test.d.ts.map +1 -0
- package/dist/cli-args.test.js +1 -0
- package/dist/crash-monitor.d.ts +16 -0
- package/dist/crash-monitor.d.ts.map +1 -0
- package/dist/crash-monitor.js +75 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -2
- package/dist/event-mapper.d.ts +16 -0
- package/dist/event-mapper.d.ts.map +1 -0
- package/dist/event-mapper.js +120 -0
- package/dist/global-setup.d.ts +3 -0
- package/dist/global-setup.d.ts.map +1 -0
- package/dist/global-setup.js +60 -0
- package/dist/global-teardown.d.ts +3 -0
- package/dist/global-teardown.d.ts.map +1 -0
- package/dist/global-teardown.js +11 -0
- package/dist/harness-reporter.d.ts +8 -0
- package/dist/harness-reporter.d.ts.map +1 -0
- package/dist/harness-reporter.js +12 -0
- package/dist/harness.d.ts +1 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +491 -386
- package/dist/jest-options.d.ts +2 -0
- package/dist/jest-options.d.ts.map +1 -0
- package/dist/jest-options.js +1 -0
- package/dist/logs.d.ts +4 -0
- package/dist/logs.d.ts.map +1 -1
- package/dist/logs.js +12 -0
- package/dist/metro-port.d.ts +16 -0
- package/dist/metro-port.d.ts.map +1 -0
- package/dist/metro-port.js +48 -0
- package/dist/resource-lock.d.ts +25 -0
- package/dist/resource-lock.d.ts.map +1 -0
- package/dist/resource-lock.js +299 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +12 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +7 -7
- package/src/__tests__/errors.test.ts +18 -6
- package/src/__tests__/harness-cache.test.ts +2 -2
- package/src/__tests__/harness.test.ts +342 -19
- package/src/__tests__/metro-port.test.ts +61 -0
- package/src/__tests__/resource-lock.test.ts +192 -0
- package/src/action-hooks.ts +66 -0
- package/src/errors.ts +23 -4
- package/src/harness.ts +609 -447
- package/src/logs.ts +24 -3
- package/src/metro-port.ts +76 -0
- package/src/resource-lock.ts +472 -0
- package/src/setup.ts +22 -2
package/src/logs.ts
CHANGED
|
@@ -25,16 +25,37 @@ export const logTestEnvironmentReady = (runner: HarnessPlatform): void => {
|
|
|
25
25
|
log(`${TAG} Runner ${chalk.bold(runner.name)} ready\n`);
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
export const logRunnerWaitingInQueue = (runner: HarnessPlatform): void => {
|
|
29
|
+
log(`${TAG} Runner ${chalk.bold(runner.name)} is busy, waiting in queue\n`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const logRunnerStillWaitingInQueue = (runner: HarnessPlatform): void => {
|
|
33
|
+
log(`${TAG} Still waiting in queue for ${chalk.bold(runner.name)} runner\n`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const logRunnerStarting = (runner: HarnessPlatform): void => {
|
|
37
|
+
log(`${TAG} Runner ${chalk.bold(runner.name)} is starting\n`);
|
|
38
|
+
};
|
|
39
|
+
|
|
28
40
|
export const logMetroPrewarmCompleted = (runner: HarnessPlatform): void => {
|
|
29
|
-
log(
|
|
30
|
-
`${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n`
|
|
31
|
-
);
|
|
41
|
+
log(`${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n`);
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
export const logMetroCacheReused = (runner: HarnessPlatform): void => {
|
|
35
45
|
log(`${TAG} Reusing Metro cache for ${chalk.bold(runner.name)}\n`);
|
|
36
46
|
};
|
|
37
47
|
|
|
48
|
+
export const logMetroPortFallback = (
|
|
49
|
+
initialPort: number,
|
|
50
|
+
selectedPort: number
|
|
51
|
+
): void => {
|
|
52
|
+
log(
|
|
53
|
+
`${TAG} Harness could not use Metro port ${chalk.bold(
|
|
54
|
+
String(initialPort)
|
|
55
|
+
)} and is using ${chalk.bold(String(selectedPort))} instead.\n`
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
38
59
|
export const getErrorMessage = (error: HarnessError): string => {
|
|
39
60
|
return `${ERROR_TAG} ${error.message}\n`;
|
|
40
61
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Config as HarnessConfig } from '@react-native-harness/config';
|
|
2
|
+
import { isPortAvailable } from '@react-native-harness/bundler-metro';
|
|
3
|
+
import type { HarnessPlatform } from '@react-native-harness/platforms';
|
|
4
|
+
import { MetroPortRangeExhaustedError } from './errors.js';
|
|
5
|
+
import type { ResourceLease, ResourceLockManager } from './resource-lock.js';
|
|
6
|
+
|
|
7
|
+
export const METRO_PORT_SCAN_ATTEMPTS = 10;
|
|
8
|
+
|
|
9
|
+
const getPortLockKey = (host: string | undefined, port: number): string => {
|
|
10
|
+
return `metro-port:${host?.trim() || '*'}:${port}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const isIosPhysicalRunner = (platform: HarnessPlatform): boolean => {
|
|
14
|
+
return platform.platformId === 'ios' && platform.name.includes('device');
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const resolveHarnessMetroPort = async (options: {
|
|
18
|
+
config: HarnessConfig;
|
|
19
|
+
platform: HarnessPlatform;
|
|
20
|
+
resourceLockManager: ResourceLockManager;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
config: HarnessConfig;
|
|
24
|
+
metroPortLease: ResourceLease | null;
|
|
25
|
+
initialMetroPort: number;
|
|
26
|
+
didFallback: boolean;
|
|
27
|
+
}> => {
|
|
28
|
+
const { config, platform, resourceLockManager, signal } = options;
|
|
29
|
+
const initialMetroPort = config.metroPort;
|
|
30
|
+
const host = config.host?.trim();
|
|
31
|
+
|
|
32
|
+
if (isIosPhysicalRunner(platform)) {
|
|
33
|
+
return {
|
|
34
|
+
config,
|
|
35
|
+
metroPortLease: null,
|
|
36
|
+
initialMetroPort,
|
|
37
|
+
didFallback: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (let attempt = 0; attempt < METRO_PORT_SCAN_ATTEMPTS; attempt += 1) {
|
|
42
|
+
const candidatePort = initialMetroPort + attempt;
|
|
43
|
+
const metroPortLease = await resourceLockManager.acquire(
|
|
44
|
+
getPortLockKey(host, candidatePort),
|
|
45
|
+
{ signal }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (!(await isPortAvailable(candidatePort, host))) {
|
|
50
|
+
await metroPortLease.release();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
config:
|
|
56
|
+
candidatePort === initialMetroPort
|
|
57
|
+
? config
|
|
58
|
+
: {
|
|
59
|
+
...config,
|
|
60
|
+
metroPort: candidatePort,
|
|
61
|
+
},
|
|
62
|
+
metroPortLease,
|
|
63
|
+
initialMetroPort,
|
|
64
|
+
didFallback: candidatePort !== initialMetroPort,
|
|
65
|
+
};
|
|
66
|
+
} catch (error) {
|
|
67
|
+
await metroPortLease.release();
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new MetroPortRangeExhaustedError(
|
|
73
|
+
initialMetroPort,
|
|
74
|
+
METRO_PORT_SCAN_ATTEMPTS
|
|
75
|
+
);
|
|
76
|
+
};
|
|
@@ -0,0 +1,472 @@
|
|
|
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 writeJsonFileAtomic = async <T>(
|
|
119
|
+
filePath: string,
|
|
120
|
+
value: T,
|
|
121
|
+
): Promise<void> => {
|
|
122
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await fs.writeFile(tempPath, JSON.stringify(value), 'utf8');
|
|
126
|
+
await fs.rename(tempPath, filePath);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
await removeFileIfPresent(tempPath);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const removeFileIfPresent = async (filePath: string): Promise<void> => {
|
|
134
|
+
try {
|
|
135
|
+
await fs.rm(filePath, { force: true });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (!isMissingFileError(error)) {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const isPidActive = (pid: number): boolean => {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return !(
|
|
149
|
+
error instanceof Error &&
|
|
150
|
+
'code' in error &&
|
|
151
|
+
typeof error.code === 'string' &&
|
|
152
|
+
error.code === 'ESRCH'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const isMetadataStale = (
|
|
158
|
+
metadata: ResourceLockMetadata,
|
|
159
|
+
now: number,
|
|
160
|
+
staleLockTimeoutMs: number,
|
|
161
|
+
isProcessActive: (pid: number) => boolean,
|
|
162
|
+
): boolean => {
|
|
163
|
+
if (!isProcessActive(metadata.pid)) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return now - metadata.heartbeatAt > staleLockTimeoutMs;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const isQueuedTicketStale = (
|
|
171
|
+
metadata: ResourceLockMetadata,
|
|
172
|
+
isProcessActive: (pid: number) => boolean,
|
|
173
|
+
): boolean => {
|
|
174
|
+
return !isProcessActive(metadata.pid);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const waitForPollInterval = (
|
|
178
|
+
ms: number,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<void> => {
|
|
181
|
+
if (!signal) {
|
|
182
|
+
return wait(ms);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (signal.aborted) {
|
|
186
|
+
return Promise.reject(signal.reason ?? createAbortError());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
signal.removeEventListener('abort', onAbort);
|
|
192
|
+
resolve();
|
|
193
|
+
}, ms);
|
|
194
|
+
const onAbort = () => {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
signal.removeEventListener('abort', onAbort);
|
|
197
|
+
reject(signal.reason ?? createAbortError());
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const readQueueTickets = async (
|
|
205
|
+
queueDir: string,
|
|
206
|
+
): Promise<ResourceLockMetadata[]> => {
|
|
207
|
+
const ticketEntries = await fs.readdir(queueDir, { withFileTypes: true });
|
|
208
|
+
const tickets = await Promise.all(
|
|
209
|
+
ticketEntries
|
|
210
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
211
|
+
.map(async (entry) => ({
|
|
212
|
+
name: entry.name,
|
|
213
|
+
metadata: await readJsonFile<ResourceLockMetadata>(
|
|
214
|
+
path.join(queueDir, entry.name),
|
|
215
|
+
),
|
|
216
|
+
})),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return tickets
|
|
220
|
+
.filter(
|
|
221
|
+
(entry): entry is { name: string; metadata: ResourceLockMetadata } =>
|
|
222
|
+
entry.metadata !== null,
|
|
223
|
+
)
|
|
224
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
225
|
+
.map((entry) => entry.metadata);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const cleanupQueue = async (options: {
|
|
229
|
+
paths: LockPaths;
|
|
230
|
+
currentTicketId: string;
|
|
231
|
+
logger: HarnessLogger;
|
|
232
|
+
isProcessActive: (pid: number) => boolean;
|
|
233
|
+
}): Promise<ResourceLockMetadata[]> => {
|
|
234
|
+
const { paths, currentTicketId, logger, isProcessActive } = options;
|
|
235
|
+
const tickets = await readQueueTickets(paths.queueDir);
|
|
236
|
+
const activeTickets: ResourceLockMetadata[] = [];
|
|
237
|
+
|
|
238
|
+
for (const ticket of tickets) {
|
|
239
|
+
const isCurrentTicket = ticket.ticketId === currentTicketId;
|
|
240
|
+
const isStale =
|
|
241
|
+
!isCurrentTicket && isQueuedTicketStale(ticket, isProcessActive);
|
|
242
|
+
|
|
243
|
+
if (isStale) {
|
|
244
|
+
logger.debug(
|
|
245
|
+
'removing stale queued ticket %s for key %s',
|
|
246
|
+
ticket.ticketId,
|
|
247
|
+
ticket.key,
|
|
248
|
+
);
|
|
249
|
+
await removeFileIfPresent(
|
|
250
|
+
path.join(paths.queueDir, `${ticket.ticketId}.json`),
|
|
251
|
+
);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
activeTickets.push(ticket);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return activeTickets;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const maybeClearStaleOwner = async (options: {
|
|
262
|
+
ownerFilePath: string;
|
|
263
|
+
staleLockTimeoutMs: number;
|
|
264
|
+
now: number;
|
|
265
|
+
logger: HarnessLogger;
|
|
266
|
+
isProcessActive: (pid: number) => boolean;
|
|
267
|
+
}): Promise<ResourceLockMetadata | null> => {
|
|
268
|
+
const { ownerFilePath, staleLockTimeoutMs, now, logger, isProcessActive } =
|
|
269
|
+
options;
|
|
270
|
+
const owner = await readJsonFile<ResourceLockMetadata>(ownerFilePath);
|
|
271
|
+
|
|
272
|
+
if (!owner) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!isMetadataStale(owner, now, staleLockTimeoutMs, isProcessActive)) {
|
|
277
|
+
return owner;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
logger.debug(
|
|
281
|
+
'removing stale owner ticket %s for key %s',
|
|
282
|
+
owner.ticketId,
|
|
283
|
+
owner.key,
|
|
284
|
+
);
|
|
285
|
+
await removeFileIfPresent(ownerFilePath);
|
|
286
|
+
return null;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const claimOwnership = async (
|
|
290
|
+
ownerFilePath: string,
|
|
291
|
+
metadata: ResourceLockMetadata,
|
|
292
|
+
): Promise<boolean> => {
|
|
293
|
+
try {
|
|
294
|
+
await fs.writeFile(ownerFilePath, JSON.stringify(metadata), {
|
|
295
|
+
encoding: 'utf8',
|
|
296
|
+
flag: 'wx',
|
|
297
|
+
});
|
|
298
|
+
return true;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (isExclusiveCreateError(error)) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const createResourceLockManager = (
|
|
309
|
+
options: ResourceLockManagerOptions = {},
|
|
310
|
+
): ResourceLockManager => {
|
|
311
|
+
const rootDir = options.rootDir ?? DEFAULT_ROOT_DIR;
|
|
312
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
313
|
+
const heartbeatIntervalMs =
|
|
314
|
+
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
315
|
+
const staleLockTimeoutMs =
|
|
316
|
+
options.staleLockTimeoutMs ?? DEFAULT_STALE_LOCK_TIMEOUT_MS;
|
|
317
|
+
const pid = options.pid ?? process.pid;
|
|
318
|
+
const scopedLogger = options.logger ?? resourceLockLogger;
|
|
319
|
+
const isProcessActive = options.isProcessActive ?? isPidActive;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
acquire: async (key, acquireOptions = {}) => {
|
|
323
|
+
const paths = getPathsForKey(rootDir, key);
|
|
324
|
+
await ensureLockDirectories(paths);
|
|
325
|
+
|
|
326
|
+
const createdAt = Date.now();
|
|
327
|
+
const ticketId = createTicketId(createdAt, pid);
|
|
328
|
+
const ticketPath = path.join(paths.queueDir, `${ticketId}.json`);
|
|
329
|
+
const metadata: ResourceLockMetadata = {
|
|
330
|
+
ticketId,
|
|
331
|
+
key,
|
|
332
|
+
pid,
|
|
333
|
+
createdAt,
|
|
334
|
+
heartbeatAt: createdAt,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
await fs.writeFile(ticketPath, JSON.stringify(metadata), 'utf8');
|
|
338
|
+
scopedLogger.debug('queued ticket %s for key %s', ticketId, key);
|
|
339
|
+
|
|
340
|
+
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
341
|
+
let heartbeatInFlight = false;
|
|
342
|
+
let released = false;
|
|
343
|
+
let didNotifyWait = false;
|
|
344
|
+
const waitStartedAt = Date.now();
|
|
345
|
+
|
|
346
|
+
const release = async () => {
|
|
347
|
+
if (released) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
released = true;
|
|
352
|
+
if (heartbeatTimer) {
|
|
353
|
+
clearInterval(heartbeatTimer);
|
|
354
|
+
heartbeatTimer = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const owner = await readJsonFile<ResourceLockMetadata>(
|
|
358
|
+
paths.ownerFilePath,
|
|
359
|
+
);
|
|
360
|
+
if (owner?.ticketId === ticketId) {
|
|
361
|
+
await removeFileIfPresent(paths.ownerFilePath);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await removeFileIfPresent(ticketPath);
|
|
365
|
+
scopedLogger.debug('released ticket %s for key %s', ticketId, key);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const startHeartbeat = () => {
|
|
369
|
+
heartbeatTimer = setInterval(async () => {
|
|
370
|
+
if (heartbeatInFlight) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
heartbeatInFlight = true;
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const nextHeartbeatAt = Date.now();
|
|
378
|
+
const owner = await readJsonFile<ResourceLockMetadata>(
|
|
379
|
+
paths.ownerFilePath,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (released || owner?.ticketId !== ticketId) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const nextMetadata: ResourceLockMetadata = {
|
|
387
|
+
...owner,
|
|
388
|
+
heartbeatAt: nextHeartbeatAt,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
if (released) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await writeJsonFileAtomic(paths.ownerFilePath, nextMetadata);
|
|
396
|
+
scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId);
|
|
397
|
+
} finally {
|
|
398
|
+
heartbeatInFlight = false;
|
|
399
|
+
}
|
|
400
|
+
}, heartbeatIntervalMs);
|
|
401
|
+
heartbeatTimer.unref?.();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
while (true) {
|
|
406
|
+
acquireOptions.signal?.throwIfAborted();
|
|
407
|
+
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
const activeTickets = await cleanupQueue({
|
|
410
|
+
paths,
|
|
411
|
+
currentTicketId: ticketId,
|
|
412
|
+
logger: scopedLogger,
|
|
413
|
+
isProcessActive,
|
|
414
|
+
});
|
|
415
|
+
const ownIndex = activeTickets.findIndex(
|
|
416
|
+
(entry) => entry.ticketId === ticketId,
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
if (ownIndex === -1) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Queued ticket ${ticketId} disappeared before acquisition.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const owner = await maybeClearStaleOwner({
|
|
426
|
+
ownerFilePath: paths.ownerFilePath,
|
|
427
|
+
staleLockTimeoutMs,
|
|
428
|
+
now,
|
|
429
|
+
logger: scopedLogger,
|
|
430
|
+
isProcessActive,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (ownIndex === 0 && owner === null) {
|
|
434
|
+
const claimed = await claimOwnership(paths.ownerFilePath, {
|
|
435
|
+
...metadata,
|
|
436
|
+
heartbeatAt: Date.now(),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (claimed) {
|
|
440
|
+
await removeFileIfPresent(ticketPath);
|
|
441
|
+
startHeartbeat();
|
|
442
|
+
scopedLogger.debug(
|
|
443
|
+
'acquired lock for key %s with ticket %s',
|
|
444
|
+
key,
|
|
445
|
+
ticketId,
|
|
446
|
+
);
|
|
447
|
+
return { release };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!didNotifyWait) {
|
|
452
|
+
didNotifyWait = true;
|
|
453
|
+
acquireOptions.onWait?.();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
acquireOptions.onStillWaiting?.(Date.now() - waitStartedAt);
|
|
457
|
+
scopedLogger.debug(
|
|
458
|
+
'waiting for key %s with ticket %s at queue position %d',
|
|
459
|
+
key,
|
|
460
|
+
ticketId,
|
|
461
|
+
ownIndex + 1,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
await waitForPollInterval(pollIntervalMs, acquireOptions.signal);
|
|
465
|
+
}
|
|
466
|
+
} catch (error) {
|
|
467
|
+
await release();
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
};
|
package/src/setup.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { logTestEnvironmentReady, logTestRunHeader } from './logs.js';
|
|
|
11
11
|
import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js';
|
|
12
12
|
import { HarnessPlatform } from '@react-native-harness/platforms';
|
|
13
13
|
import { logger } from '@react-native-harness/tools';
|
|
14
|
+
import { createActionHooksPlugin } from './action-hooks.js';
|
|
14
15
|
|
|
15
16
|
const setupLogger = logger.child('setup');
|
|
16
17
|
|
|
@@ -66,15 +67,34 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
|
|
|
66
67
|
const cliArgs = getAdditionalCliArgs();
|
|
67
68
|
|
|
68
69
|
if (cliArgs.metroPort != null) {
|
|
69
|
-
setupLogger.debug(
|
|
70
|
+
setupLogger.debug(
|
|
71
|
+
'applying CLI metro port override: %d',
|
|
72
|
+
cliArgs.metroPort
|
|
73
|
+
);
|
|
70
74
|
harnessConfig = ConfigSchema.parse({
|
|
71
75
|
...harnessConfig,
|
|
72
76
|
metroPort: cliArgs.metroPort,
|
|
73
77
|
});
|
|
74
78
|
}
|
|
75
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
|
+
|
|
76
87
|
const selectedRunner = getHarnessRunner(harnessConfig, cliArgs);
|
|
77
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
|
+
|
|
78
98
|
if (globalConfig.collectCoverage) {
|
|
79
99
|
// This is going to be used by @react-native-harness/babel-preset
|
|
80
100
|
// to enable instrumentation of test files.
|
|
@@ -102,6 +122,6 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
|
|
|
102
122
|
logTestEnvironmentReady(selectedRunner);
|
|
103
123
|
setupLogger.debug('Harness instance is ready');
|
|
104
124
|
|
|
105
|
-
global.HARNESS_CONFIG =
|
|
125
|
+
global.HARNESS_CONFIG = harness.config;
|
|
106
126
|
global.HARNESS = harness;
|
|
107
127
|
};
|