@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
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,2BAA2B,EAC3B,MAAM,EACN,aAAa,EACb,WAAW,EACX,aAAa,EACb,IAAI,EACJ,iBAAiB,EACjB,WAAW,EACZ,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,KAAK,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,2BAA2B,EAC3B,MAAM,EACN,aAAa,EACb,WAAW,EACX,aAAa,EACb,IAAI,EACJ,iBAAiB,EACjB,WAAW,EACZ,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,KAAK,OAAO,EAAwB,MAAM,cAAc,CAAC;AA0ClE,MAAM,CAAC,OAAO,OAAO,WAAY,YAAW,2BAA2B;;IACrE,QAAQ,CAAC,QAAQ,QAAQ;gBAIb,YAAY,EAAE,MAAM,CAAC,YAAY;IAIvC,QAAQ,CACZ,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,EAClB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,aAAa,EACvB,SAAS,EAAE,aAAa,EACxB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC;IAkCV,oBAAoB,CACxB,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,EAClB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,OAAO,EAChB,aAAa,EAAE,aAAa,EAC5B,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,aAAa,EACvB,SAAS,EAAE,aAAa,GACvB,OAAO,CAAC,IAAI,CAAC;CAyQjB"}
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,23 @@ import { teardown } from './teardown.js';
|
|
|
5
5
|
import { HarnessError } from '@react-native-harness/tools';
|
|
6
6
|
import { getErrorMessage } from './logs.js';
|
|
7
7
|
import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
|
|
8
|
-
import { NativeCrashError } from './errors.js';
|
|
8
|
+
import { NativeCrashError, StartupStallError } from './errors.js';
|
|
9
|
+
import { logger } from '@react-native-harness/tools';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
const runLogger = logger.child('run');
|
|
13
|
+
const createRunSummary = () => ({
|
|
14
|
+
passed: 0,
|
|
15
|
+
failed: 0,
|
|
16
|
+
skipped: 0,
|
|
17
|
+
todo: 0,
|
|
18
|
+
});
|
|
19
|
+
const applyJestResultToSummary = (summary, result) => {
|
|
20
|
+
summary.passed += result.numPassingTests;
|
|
21
|
+
summary.failed += result.numFailingTests;
|
|
22
|
+
summary.skipped += result.numPendingTests;
|
|
23
|
+
summary.todo += result.numTodoTests;
|
|
24
|
+
};
|
|
9
25
|
class CancelRun extends Error {
|
|
10
26
|
constructor(message) {
|
|
11
27
|
super(message);
|
|
@@ -44,64 +60,195 @@ export default class JestHarness {
|
|
|
44
60
|
async _createInBandTestRun(tests, watcher, harness, harnessConfig, onStart, onResult, onFailure) {
|
|
45
61
|
const mutex = pLimit(1);
|
|
46
62
|
let isFirstTest = true;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
const runId = randomUUID();
|
|
65
|
+
const watchMode = this.#globalConfig.watch || this.#globalConfig.watchAll;
|
|
66
|
+
const rootDir = this.#globalConfig.rootDir ?? process.cwd();
|
|
67
|
+
const testFiles = tests.map((test) => path.relative(rootDir, test.path));
|
|
68
|
+
const summary = createRunSummary();
|
|
69
|
+
const updateRunState = (overrides = {}) => {
|
|
70
|
+
const nextRunState = {
|
|
71
|
+
runId,
|
|
72
|
+
startTime,
|
|
73
|
+
testFiles,
|
|
74
|
+
watchMode,
|
|
75
|
+
coverageEnabled: this.#globalConfig.collectCoverage,
|
|
76
|
+
summary,
|
|
77
|
+
status: summary.failed > 0 ? 'failed' : 'passed',
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
harness.setRunState(nextRunState);
|
|
81
|
+
return nextRunState;
|
|
82
|
+
};
|
|
83
|
+
updateRunState();
|
|
84
|
+
runLogger.debug('run started: runId=%s files=%d watchMode=%s coverage=%s', runId, testFiles.length, watchMode, this.#globalConfig.collectCoverage);
|
|
85
|
+
await harness.callHook('run:started', {
|
|
86
|
+
runId,
|
|
87
|
+
startTime,
|
|
88
|
+
testFiles,
|
|
89
|
+
watchMode,
|
|
90
|
+
coverageEnabled: this.#globalConfig.collectCoverage,
|
|
91
|
+
});
|
|
92
|
+
await harness.callHook('metro:initialized', {
|
|
93
|
+
runId,
|
|
94
|
+
port: harnessConfig.metroPort,
|
|
95
|
+
host: harnessConfig.host?.trim() || undefined,
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await tests.reduce((promise, test) => mutex(() => {
|
|
99
|
+
let didApplySummary = false;
|
|
100
|
+
return promise
|
|
101
|
+
.then(async () => {
|
|
102
|
+
const relativeTestPath = path.relative(rootDir, test.path);
|
|
103
|
+
const testFileStartedAt = Date.now();
|
|
104
|
+
let didEmitTestFileFinished = false;
|
|
105
|
+
await harness.callHook('test-file:started', {
|
|
106
|
+
runId,
|
|
107
|
+
file: relativeTestPath,
|
|
65
108
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
runLogger.debug('test file started: %s', relativeTestPath);
|
|
110
|
+
const emitTestFileFinished = async (options) => {
|
|
111
|
+
didEmitTestFileFinished = true;
|
|
112
|
+
runLogger.debug('test file finished: %s status=%s duration=%dms', relativeTestPath, options.status, options.duration);
|
|
113
|
+
await harness.callHook('test-file:finished', {
|
|
114
|
+
runId,
|
|
115
|
+
file: relativeTestPath,
|
|
116
|
+
duration: options.duration,
|
|
117
|
+
status: options.status,
|
|
118
|
+
result: options.result,
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
if (watcher.isInterrupted()) {
|
|
123
|
+
throw new CancelRun();
|
|
124
|
+
}
|
|
125
|
+
if (harnessConfig.resetEnvironmentBetweenTestFiles &&
|
|
126
|
+
!isFirstTest) {
|
|
127
|
+
runLogger.debug('resetting environment before %s', relativeTestPath);
|
|
128
|
+
await harness.restart(test.path);
|
|
129
|
+
}
|
|
130
|
+
isFirstTest = false;
|
|
131
|
+
const result = await onStart(test).then(async () => {
|
|
132
|
+
if (!harnessConfig.detectNativeCrashes) {
|
|
133
|
+
runLogger.debug('native crash detection disabled for %s', relativeTestPath);
|
|
134
|
+
await harness.ensureAppReady(test.path);
|
|
135
|
+
return runHarnessTestFile({
|
|
136
|
+
testPath: test.path,
|
|
137
|
+
harness,
|
|
138
|
+
globalConfig: this.#globalConfig,
|
|
139
|
+
projectConfig: test.context.config,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
await harness.ensureAppReady(test.path);
|
|
143
|
+
harness.crashSupervisor.beginTestRun(test.path);
|
|
144
|
+
runLogger.debug('native crash detection armed for %s', relativeTestPath);
|
|
145
|
+
const crashPromise = harness.crashSupervisor.waitForCrash(test.path);
|
|
146
|
+
try {
|
|
147
|
+
return await Promise.race([
|
|
148
|
+
runHarnessTestFile({
|
|
149
|
+
testPath: test.path,
|
|
150
|
+
harness,
|
|
151
|
+
globalConfig: this.#globalConfig,
|
|
152
|
+
projectConfig: test.context.config,
|
|
153
|
+
}),
|
|
154
|
+
crashPromise,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
harness.crashSupervisor.cancelCrashWaiters();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
applyJestResultToSummary(summary, result.jestResult);
|
|
162
|
+
didApplySummary = true;
|
|
163
|
+
updateRunState();
|
|
164
|
+
await emitTestFileFinished({
|
|
165
|
+
status: result.harnessResult.status,
|
|
166
|
+
duration: result.duration,
|
|
167
|
+
result: result.harnessResult,
|
|
168
|
+
});
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (!didEmitTestFileFinished) {
|
|
173
|
+
await emitTestFileFinished({
|
|
174
|
+
status: 'failed',
|
|
175
|
+
duration: Date.now() - testFileStartedAt,
|
|
176
|
+
result: null,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
.then(async (result) => {
|
|
183
|
+
if (!result) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
await onResult(test, result.jestResult);
|
|
187
|
+
})
|
|
188
|
+
.catch(async (err) => {
|
|
189
|
+
if (err instanceof NativeCrashError ||
|
|
190
|
+
err instanceof StartupStallError ||
|
|
191
|
+
err instanceof DeviceNotRespondingError) {
|
|
192
|
+
runLogger.debug('classified runtime failure for %s: %s', test.path, err.name);
|
|
193
|
+
summary.failed += 1;
|
|
194
|
+
updateRunState();
|
|
195
|
+
}
|
|
196
|
+
if (err instanceof NativeCrashError) {
|
|
197
|
+
harness.crashSupervisor.reset();
|
|
198
|
+
onFailure(test, {
|
|
199
|
+
message: err.message,
|
|
200
|
+
stack: '',
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (err instanceof StartupStallError) {
|
|
205
|
+
onFailure(test, {
|
|
206
|
+
message: err.message,
|
|
207
|
+
stack: '',
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (err instanceof DeviceNotRespondingError) {
|
|
212
|
+
onFailure(test, {
|
|
213
|
+
message: err.message,
|
|
214
|
+
stack: '',
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!(err instanceof CancelRun) && !didApplySummary) {
|
|
219
|
+
summary.failed += 1;
|
|
220
|
+
}
|
|
221
|
+
updateRunState({ error: err });
|
|
222
|
+
onFailure(test, err);
|
|
94
223
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
224
|
+
}), Promise.resolve());
|
|
225
|
+
const runState = updateRunState();
|
|
226
|
+
runLogger.debug('run finished: runId=%s status=%s duration=%dms', runId, runState.status ?? 'passed', Date.now() - startTime);
|
|
227
|
+
await harness.callHook('run:finished', {
|
|
228
|
+
runId,
|
|
229
|
+
startTime,
|
|
230
|
+
duration: Date.now() - startTime,
|
|
231
|
+
testFiles,
|
|
232
|
+
summary,
|
|
233
|
+
status: runState.status ?? 'passed',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const runState = updateRunState({
|
|
238
|
+
error,
|
|
239
|
+
status: 'failed',
|
|
240
|
+
});
|
|
241
|
+
runLogger.debug('run failed: runId=%s status=%s', runId, runState.status ?? 'failed');
|
|
242
|
+
await harness.callHook('run:finished', {
|
|
243
|
+
runId,
|
|
244
|
+
startTime,
|
|
245
|
+
duration: Date.now() - startTime,
|
|
246
|
+
testFiles,
|
|
247
|
+
summary,
|
|
248
|
+
status: runState.status ?? 'failed',
|
|
249
|
+
error,
|
|
250
|
+
});
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
106
253
|
}
|
|
107
254
|
}
|
package/dist/logs.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { HarnessError } from '@react-native-harness/tools';
|
|
|
3
3
|
export declare const log: (message: string) => void;
|
|
4
4
|
export declare const logTestRunHeader: (runner: HarnessPlatform) => void;
|
|
5
5
|
export declare const logTestEnvironmentReady: (runner: HarnessPlatform) => void;
|
|
6
|
+
export declare const logRunnerWaitingInQueue: (runner: HarnessPlatform) => void;
|
|
7
|
+
export declare const logRunnerStillWaitingInQueue: (runner: HarnessPlatform) => void;
|
|
8
|
+
export declare const logRunnerStarting: (runner: HarnessPlatform) => void;
|
|
6
9
|
export declare const logMetroPrewarmCompleted: (runner: HarnessPlatform) => void;
|
|
10
|
+
export declare const logMetroCacheReused: (runner: HarnessPlatform) => void;
|
|
7
11
|
export declare const getErrorMessage: (error: HarnessError) => string;
|
|
8
12
|
//# sourceMappingURL=logs.d.ts.map
|
package/dist/logs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logs.d.ts","sourceRoot":"","sources":["../src/logs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAY3D,eAAO,MAAM,GAAG,GAAI,SAAS,MAAM,KAAG,IAErC,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,QAAQ,eAAe,KAAG,IAI1D,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,eAAe,KAAG,IAEjE,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,QAAQ,eAAe,KAAG,
|
|
1
|
+
{"version":3,"file":"logs.d.ts","sourceRoot":"","sources":["../src/logs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAY3D,eAAO,MAAM,GAAG,GAAI,SAAS,MAAM,KAAG,IAErC,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,QAAQ,eAAe,KAAG,IAI1D,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,eAAe,KAAG,IAEjE,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,eAAe,KAAG,IAEjE,CAAC;AAEF,eAAO,MAAM,4BAA4B,GAAI,QAAQ,eAAe,KAAG,IAEtE,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,QAAQ,eAAe,KAAG,IAE3D,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,QAAQ,eAAe,KAAG,IAElE,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAAI,QAAQ,eAAe,KAAG,IAE7D,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,OAAO,YAAY,KAAG,MAErD,CAAC"}
|
package/dist/logs.js
CHANGED
|
@@ -15,9 +15,21 @@ export const logTestRunHeader = (runner) => {
|
|
|
15
15
|
export const logTestEnvironmentReady = (runner) => {
|
|
16
16
|
log(`${TAG} Runner ${chalk.bold(runner.name)} ready\n`);
|
|
17
17
|
};
|
|
18
|
+
export const logRunnerWaitingInQueue = (runner) => {
|
|
19
|
+
log(`${TAG} Runner ${chalk.bold(runner.name)} is busy, waiting in queue\n`);
|
|
20
|
+
};
|
|
21
|
+
export const logRunnerStillWaitingInQueue = (runner) => {
|
|
22
|
+
log(`${TAG} Still waiting in queue for ${chalk.bold(runner.name)} runner\n`);
|
|
23
|
+
};
|
|
24
|
+
export const logRunnerStarting = (runner) => {
|
|
25
|
+
log(`${TAG} Runner ${chalk.bold(runner.name)} is starting\n`);
|
|
26
|
+
};
|
|
18
27
|
export const logMetroPrewarmCompleted = (runner) => {
|
|
19
28
|
log(`${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n`);
|
|
20
29
|
};
|
|
30
|
+
export const logMetroCacheReused = (runner) => {
|
|
31
|
+
log(`${TAG} Reusing Metro cache for ${chalk.bold(runner.name)}\n`);
|
|
32
|
+
};
|
|
21
33
|
export const getErrorMessage = (error) => {
|
|
22
34
|
return `${ERROR_TAG} ${error.message}\n`;
|
|
23
35
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type HarnessLogger } from '@react-native-harness/tools';
|
|
2
|
+
export type ResourceLockAcquireOptions = {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
onWait?: () => void;
|
|
5
|
+
onStillWaiting?: (elapsedMs: number) => void;
|
|
6
|
+
};
|
|
7
|
+
export type ResourceLease = {
|
|
8
|
+
release: () => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
export type ResourceLockManager = {
|
|
11
|
+
acquire: (key: string, options?: ResourceLockAcquireOptions) => Promise<ResourceLease>;
|
|
12
|
+
};
|
|
13
|
+
type ResourceLockManagerOptions = {
|
|
14
|
+
rootDir?: string;
|
|
15
|
+
pollIntervalMs?: number;
|
|
16
|
+
heartbeatIntervalMs?: number;
|
|
17
|
+
staleLockTimeoutMs?: number;
|
|
18
|
+
pid?: number;
|
|
19
|
+
logger?: HarnessLogger;
|
|
20
|
+
isProcessActive?: (pid: number) => boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare const hashResourceLockKey: (key: string) => string;
|
|
23
|
+
export declare const createResourceLockManager: (options?: ResourceLockManagerOptions) => ResourceLockManager;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=resource-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource-lock.d.ts","sourceRoot":"","sources":["../src/resource-lock.ts"],"names":[],"mappings":"AAIA,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAiBzE,MAAM,MAAM,0BAA0B,GAAG;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,CACP,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,0BAA0B,KACjC,OAAO,CAAC,aAAa,CAAC,CAAC;CAC7B,CAAC;AAEF,KAAK,0BAA0B,GAAG;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CAC5C,CAAC;AAgBF,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,KAAG,MAEjD,CAAC;AAoOF,eAAO,MAAM,yBAAyB,GACpC,UAAS,0BAA+B,KACvC,mBA2JF,CAAC"}
|
|
@@ -0,0 +1,278 @@
|
|
|
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 } from '@react-native-harness/tools';
|
|
6
|
+
const resourceLockLogger = logger.child('resource-lock');
|
|
7
|
+
const DEFAULT_ROOT_DIR = path.join(os.tmpdir(), 'react-native-harness-locks');
|
|
8
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
9
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 2000;
|
|
10
|
+
const DEFAULT_STALE_LOCK_TIMEOUT_MS = 15000;
|
|
11
|
+
const wait = async (ms) => {
|
|
12
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
};
|
|
14
|
+
const createAbortError = () => new DOMException('The operation was aborted', 'AbortError');
|
|
15
|
+
export const hashResourceLockKey = (key) => {
|
|
16
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
17
|
+
};
|
|
18
|
+
const getPathsForKey = (rootDir, key) => {
|
|
19
|
+
const keyDir = path.join(rootDir, hashResourceLockKey(key));
|
|
20
|
+
return {
|
|
21
|
+
rootDir,
|
|
22
|
+
keyDir,
|
|
23
|
+
queueDir: path.join(keyDir, 'queue'),
|
|
24
|
+
ownerFilePath: path.join(keyDir, 'owner.json'),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const createTicketId = (createdAt, pid) => {
|
|
28
|
+
return `${createdAt
|
|
29
|
+
.toString()
|
|
30
|
+
.padStart(16, '0')}-${pid}-${crypto.randomUUID()}`;
|
|
31
|
+
};
|
|
32
|
+
const isMissingFileError = (error) => {
|
|
33
|
+
return (error instanceof Error &&
|
|
34
|
+
'code' in error &&
|
|
35
|
+
typeof error.code === 'string' &&
|
|
36
|
+
error.code === 'ENOENT');
|
|
37
|
+
};
|
|
38
|
+
const isExclusiveCreateError = (error) => {
|
|
39
|
+
return (error instanceof Error &&
|
|
40
|
+
'code' in error &&
|
|
41
|
+
typeof error.code === 'string' &&
|
|
42
|
+
error.code === 'EEXIST');
|
|
43
|
+
};
|
|
44
|
+
const ensureLockDirectories = async (paths) => {
|
|
45
|
+
await fs.mkdir(paths.queueDir, { recursive: true });
|
|
46
|
+
};
|
|
47
|
+
const readJsonFile = async (filePath) => {
|
|
48
|
+
try {
|
|
49
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
50
|
+
return JSON.parse(content);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (isMissingFileError(error)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const removeFileIfPresent = async (filePath) => {
|
|
60
|
+
try {
|
|
61
|
+
await fs.rm(filePath, { force: true });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (!isMissingFileError(error)) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const isPidActive = (pid) => {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return !(error instanceof Error &&
|
|
76
|
+
'code' in error &&
|
|
77
|
+
typeof error.code === 'string' &&
|
|
78
|
+
error.code === 'ESRCH');
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const isMetadataStale = (metadata, now, staleLockTimeoutMs, isProcessActive) => {
|
|
82
|
+
if (!isProcessActive(metadata.pid)) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return now - metadata.heartbeatAt > staleLockTimeoutMs;
|
|
86
|
+
};
|
|
87
|
+
const isQueuedTicketStale = (metadata, isProcessActive) => {
|
|
88
|
+
return !isProcessActive(metadata.pid);
|
|
89
|
+
};
|
|
90
|
+
const waitForPollInterval = (ms, signal) => {
|
|
91
|
+
if (!signal) {
|
|
92
|
+
return wait(ms);
|
|
93
|
+
}
|
|
94
|
+
if (signal.aborted) {
|
|
95
|
+
return Promise.reject(signal.reason ?? createAbortError());
|
|
96
|
+
}
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
signal.removeEventListener('abort', onAbort);
|
|
100
|
+
resolve();
|
|
101
|
+
}, ms);
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
signal.removeEventListener('abort', onAbort);
|
|
105
|
+
reject(signal.reason ?? createAbortError());
|
|
106
|
+
};
|
|
107
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
const readQueueTickets = async (queueDir) => {
|
|
111
|
+
const ticketEntries = await fs.readdir(queueDir, { withFileTypes: true });
|
|
112
|
+
const tickets = await Promise.all(ticketEntries
|
|
113
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
114
|
+
.map(async (entry) => ({
|
|
115
|
+
name: entry.name,
|
|
116
|
+
metadata: await readJsonFile(path.join(queueDir, entry.name)),
|
|
117
|
+
})));
|
|
118
|
+
return tickets
|
|
119
|
+
.filter((entry) => entry.metadata !== null)
|
|
120
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
121
|
+
.map((entry) => entry.metadata);
|
|
122
|
+
};
|
|
123
|
+
const cleanupQueue = async (options) => {
|
|
124
|
+
const { paths, currentTicketId, logger, isProcessActive } = options;
|
|
125
|
+
const tickets = await readQueueTickets(paths.queueDir);
|
|
126
|
+
const activeTickets = [];
|
|
127
|
+
for (const ticket of tickets) {
|
|
128
|
+
const isCurrentTicket = ticket.ticketId === currentTicketId;
|
|
129
|
+
const isStale = !isCurrentTicket && isQueuedTicketStale(ticket, isProcessActive);
|
|
130
|
+
if (isStale) {
|
|
131
|
+
logger.debug('removing stale queued ticket %s for key %s', ticket.ticketId, ticket.key);
|
|
132
|
+
await removeFileIfPresent(path.join(paths.queueDir, `${ticket.ticketId}.json`));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
activeTickets.push(ticket);
|
|
136
|
+
}
|
|
137
|
+
return activeTickets;
|
|
138
|
+
};
|
|
139
|
+
const maybeClearStaleOwner = async (options) => {
|
|
140
|
+
const { ownerFilePath, staleLockTimeoutMs, now, logger, isProcessActive } = options;
|
|
141
|
+
const owner = await readJsonFile(ownerFilePath);
|
|
142
|
+
if (!owner) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (!isMetadataStale(owner, now, staleLockTimeoutMs, isProcessActive)) {
|
|
146
|
+
return owner;
|
|
147
|
+
}
|
|
148
|
+
logger.debug('removing stale owner ticket %s for key %s', owner.ticketId, owner.key);
|
|
149
|
+
await removeFileIfPresent(ownerFilePath);
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
const claimOwnership = async (ownerFilePath, metadata) => {
|
|
153
|
+
try {
|
|
154
|
+
await fs.writeFile(ownerFilePath, JSON.stringify(metadata), {
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
flag: 'wx',
|
|
157
|
+
});
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (isExclusiveCreateError(error)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
export const createResourceLockManager = (options = {}) => {
|
|
168
|
+
const rootDir = options.rootDir ?? DEFAULT_ROOT_DIR;
|
|
169
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
170
|
+
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
171
|
+
const staleLockTimeoutMs = options.staleLockTimeoutMs ?? DEFAULT_STALE_LOCK_TIMEOUT_MS;
|
|
172
|
+
const pid = options.pid ?? process.pid;
|
|
173
|
+
const scopedLogger = options.logger ?? resourceLockLogger;
|
|
174
|
+
const isProcessActive = options.isProcessActive ?? isPidActive;
|
|
175
|
+
return {
|
|
176
|
+
acquire: async (key, acquireOptions = {}) => {
|
|
177
|
+
const paths = getPathsForKey(rootDir, key);
|
|
178
|
+
await ensureLockDirectories(paths);
|
|
179
|
+
const createdAt = Date.now();
|
|
180
|
+
const ticketId = createTicketId(createdAt, pid);
|
|
181
|
+
const ticketPath = path.join(paths.queueDir, `${ticketId}.json`);
|
|
182
|
+
const metadata = {
|
|
183
|
+
ticketId,
|
|
184
|
+
key,
|
|
185
|
+
pid,
|
|
186
|
+
createdAt,
|
|
187
|
+
heartbeatAt: createdAt,
|
|
188
|
+
};
|
|
189
|
+
await fs.writeFile(ticketPath, JSON.stringify(metadata), 'utf8');
|
|
190
|
+
scopedLogger.debug('queued ticket %s for key %s', ticketId, key);
|
|
191
|
+
let heartbeatTimer = null;
|
|
192
|
+
let released = false;
|
|
193
|
+
let didNotifyWait = false;
|
|
194
|
+
const waitStartedAt = Date.now();
|
|
195
|
+
const release = async () => {
|
|
196
|
+
if (released) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
released = true;
|
|
200
|
+
if (heartbeatTimer) {
|
|
201
|
+
clearInterval(heartbeatTimer);
|
|
202
|
+
heartbeatTimer = null;
|
|
203
|
+
}
|
|
204
|
+
const owner = await readJsonFile(paths.ownerFilePath);
|
|
205
|
+
if (owner?.ticketId === ticketId) {
|
|
206
|
+
await removeFileIfPresent(paths.ownerFilePath);
|
|
207
|
+
}
|
|
208
|
+
await removeFileIfPresent(ticketPath);
|
|
209
|
+
scopedLogger.debug('released ticket %s for key %s', ticketId, key);
|
|
210
|
+
};
|
|
211
|
+
const startHeartbeat = () => {
|
|
212
|
+
heartbeatTimer = setInterval(async () => {
|
|
213
|
+
const nextHeartbeatAt = Date.now();
|
|
214
|
+
const owner = await readJsonFile(paths.ownerFilePath);
|
|
215
|
+
if (released || owner?.ticketId !== ticketId) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const nextMetadata = {
|
|
219
|
+
...owner,
|
|
220
|
+
heartbeatAt: nextHeartbeatAt,
|
|
221
|
+
};
|
|
222
|
+
if (released) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await fs.writeFile(paths.ownerFilePath, JSON.stringify(nextMetadata), 'utf8');
|
|
226
|
+
scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId);
|
|
227
|
+
}, heartbeatIntervalMs);
|
|
228
|
+
heartbeatTimer.unref?.();
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
while (true) {
|
|
232
|
+
acquireOptions.signal?.throwIfAborted();
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const activeTickets = await cleanupQueue({
|
|
235
|
+
paths,
|
|
236
|
+
currentTicketId: ticketId,
|
|
237
|
+
logger: scopedLogger,
|
|
238
|
+
isProcessActive,
|
|
239
|
+
});
|
|
240
|
+
const ownIndex = activeTickets.findIndex((entry) => entry.ticketId === ticketId);
|
|
241
|
+
if (ownIndex === -1) {
|
|
242
|
+
throw new Error(`Queued ticket ${ticketId} disappeared before acquisition.`);
|
|
243
|
+
}
|
|
244
|
+
const owner = await maybeClearStaleOwner({
|
|
245
|
+
ownerFilePath: paths.ownerFilePath,
|
|
246
|
+
staleLockTimeoutMs,
|
|
247
|
+
now,
|
|
248
|
+
logger: scopedLogger,
|
|
249
|
+
isProcessActive,
|
|
250
|
+
});
|
|
251
|
+
if (ownIndex === 0 && owner === null) {
|
|
252
|
+
const claimed = await claimOwnership(paths.ownerFilePath, {
|
|
253
|
+
...metadata,
|
|
254
|
+
heartbeatAt: Date.now(),
|
|
255
|
+
});
|
|
256
|
+
if (claimed) {
|
|
257
|
+
await removeFileIfPresent(ticketPath);
|
|
258
|
+
startHeartbeat();
|
|
259
|
+
scopedLogger.debug('acquired lock for key %s with ticket %s', key, ticketId);
|
|
260
|
+
return { release };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!didNotifyWait) {
|
|
264
|
+
didNotifyWait = true;
|
|
265
|
+
acquireOptions.onWait?.();
|
|
266
|
+
}
|
|
267
|
+
acquireOptions.onStillWaiting?.(Date.now() - waitStartedAt);
|
|
268
|
+
scopedLogger.debug('waiting for key %s with ticket %s at queue position %d', key, ticketId, ownIndex + 1);
|
|
269
|
+
await waitForPollInterval(pollIntervalMs, acquireOptions.signal);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
await release();
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
};
|
package/dist/run.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { Config as JestConfig } from 'jest-runner';
|
|
2
2
|
import { TestResult as JestTestResult } from '@jest/test-result';
|
|
3
|
+
import type { TestSuiteResult as HarnessTestSuiteResult } from '@react-native-harness/bridge';
|
|
3
4
|
import type { Harness } from './harness.js';
|
|
4
5
|
export type RunHarnessTestFile = (options: {
|
|
5
6
|
testPath: string;
|
|
6
7
|
harness: Harness;
|
|
7
8
|
projectConfig: JestConfig.ProjectConfig;
|
|
8
9
|
globalConfig: JestConfig.GlobalConfig;
|
|
9
|
-
}) => Promise<
|
|
10
|
+
}) => Promise<{
|
|
11
|
+
jestResult: JestTestResult;
|
|
12
|
+
harnessResult: HarnessTestSuiteResult;
|
|
13
|
+
relativeTestPath: string;
|
|
14
|
+
duration: number;
|
|
15
|
+
}>;
|
|
10
16
|
export declare const runHarnessTestFile: RunHarnessTestFile;
|
|
11
17
|
//# sourceMappingURL=run.d.ts.map
|
package/dist/run.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,KAAK,EACV,eAAe,IAAI,sBAAsB,EAE1C,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAuD5C,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,UAAU,CAAC,aAAa,CAAC;IACxC,YAAY,EAAE,UAAU,CAAC,YAAY,CAAC;CACvC,KAAK,OAAO,CAAC;IACZ,UAAU,EAAE,cAAc,CAAC;IAC3B,aAAa,EAAE,sBAAsB,CAAC;IACtC,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH,eAAO,MAAM,kBAAkB,EAAE,kBAkFhC,CAAC"}
|