@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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
createResourceLockManager,
|
|
7
|
+
hashResourceLockKey,
|
|
8
|
+
} from '../resource-lock.js';
|
|
9
|
+
|
|
10
|
+
describe('resource lock manager', () => {
|
|
11
|
+
let rootDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
rootDir = await fs.mkdtemp(
|
|
15
|
+
path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-'),
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fs.rm(rootDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('queues access in FIFO order', async () => {
|
|
24
|
+
const manager = createResourceLockManager({
|
|
25
|
+
rootDir,
|
|
26
|
+
pollIntervalMs: 5,
|
|
27
|
+
heartbeatIntervalMs: 20,
|
|
28
|
+
staleLockTimeoutMs: 200,
|
|
29
|
+
});
|
|
30
|
+
const order: string[] = [];
|
|
31
|
+
|
|
32
|
+
const firstLease = await manager.acquire(
|
|
33
|
+
'ios:simulator:iPhone 17 Pro:26.2',
|
|
34
|
+
);
|
|
35
|
+
const secondAcquire = manager
|
|
36
|
+
.acquire('ios:simulator:iPhone 17 Pro:26.2', {
|
|
37
|
+
onWait: () => {
|
|
38
|
+
order.push('waiting');
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
.then(async (lease) => {
|
|
42
|
+
order.push('acquired');
|
|
43
|
+
await lease.release();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
47
|
+
expect(order).toEqual(['waiting']);
|
|
48
|
+
|
|
49
|
+
await firstLease.release();
|
|
50
|
+
await secondAcquire;
|
|
51
|
+
|
|
52
|
+
expect(order).toEqual(['waiting', 'acquired']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('removes the queued ticket when waiting is aborted', async () => {
|
|
56
|
+
const manager = createResourceLockManager({
|
|
57
|
+
rootDir,
|
|
58
|
+
pollIntervalMs: 5,
|
|
59
|
+
heartbeatIntervalMs: 20,
|
|
60
|
+
staleLockTimeoutMs: 200,
|
|
61
|
+
});
|
|
62
|
+
const key = 'android:emulator:Pixel_8_API_35';
|
|
63
|
+
const firstLease = await manager.acquire(key);
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
|
|
66
|
+
const acquirePromise = manager.acquire(key, {
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
71
|
+
controller.abort();
|
|
72
|
+
|
|
73
|
+
await expect(acquirePromise).rejects.toMatchObject({
|
|
74
|
+
name: 'AbortError',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const queueDir = path.join(rootDir, hashResourceLockKey(key), 'queue');
|
|
78
|
+
const queuedEntries = await fs.readdir(queueDir);
|
|
79
|
+
expect(queuedEntries).toHaveLength(0);
|
|
80
|
+
|
|
81
|
+
await firstLease.release();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps queued tickets alive while the waiting process is still active', async () => {
|
|
85
|
+
const manager = createResourceLockManager({
|
|
86
|
+
rootDir,
|
|
87
|
+
pollIntervalMs: 5,
|
|
88
|
+
heartbeatIntervalMs: 20,
|
|
89
|
+
staleLockTimeoutMs: 30,
|
|
90
|
+
isProcessActive: () => true,
|
|
91
|
+
});
|
|
92
|
+
const key = 'ios:simulator:iPhone 17 Pro:26.2';
|
|
93
|
+
const firstLease = await manager.acquire(key);
|
|
94
|
+
|
|
95
|
+
const secondAcquire = manager.acquire(key);
|
|
96
|
+
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
98
|
+
|
|
99
|
+
await firstLease.release();
|
|
100
|
+
const secondLease = await secondAcquire;
|
|
101
|
+
await secondLease.release();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('reclaims a stale owner before granting the lock', async () => {
|
|
105
|
+
const manager = createResourceLockManager({
|
|
106
|
+
rootDir,
|
|
107
|
+
pollIntervalMs: 5,
|
|
108
|
+
heartbeatIntervalMs: 20,
|
|
109
|
+
staleLockTimeoutMs: 50,
|
|
110
|
+
isProcessActive: () => false,
|
|
111
|
+
});
|
|
112
|
+
const key = 'web:browser:chromium';
|
|
113
|
+
const keyDir = path.join(rootDir, hashResourceLockKey(key));
|
|
114
|
+
const queueDir = path.join(keyDir, 'queue');
|
|
115
|
+
const ownerFilePath = path.join(keyDir, 'owner.json');
|
|
116
|
+
|
|
117
|
+
await fs.mkdir(queueDir, { recursive: true });
|
|
118
|
+
await fs.writeFile(
|
|
119
|
+
ownerFilePath,
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
ticketId: 'stale-owner',
|
|
122
|
+
key,
|
|
123
|
+
pid: 999999,
|
|
124
|
+
createdAt: Date.now() - 1000,
|
|
125
|
+
heartbeatAt: Date.now() - 1000,
|
|
126
|
+
}),
|
|
127
|
+
'utf8',
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const lease = await manager.acquire(key);
|
|
131
|
+
const owner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8')) as {
|
|
132
|
+
ticketId: string;
|
|
133
|
+
};
|
|
134
|
+
expect(owner.ticketId).not.toBe('stale-owner');
|
|
135
|
+
|
|
136
|
+
await lease.release();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('keeps owner metadata valid when heartbeat writes overlap', async () => {
|
|
140
|
+
const manager = createResourceLockManager({
|
|
141
|
+
rootDir,
|
|
142
|
+
pollIntervalMs: 5,
|
|
143
|
+
heartbeatIntervalMs: 10,
|
|
144
|
+
staleLockTimeoutMs: 200,
|
|
145
|
+
});
|
|
146
|
+
const key = 'ios:simulator:iPhone 17 Pro:26.2';
|
|
147
|
+
const ownerFilePath = path.join(
|
|
148
|
+
rootDir,
|
|
149
|
+
hashResourceLockKey(key),
|
|
150
|
+
'owner.json',
|
|
151
|
+
);
|
|
152
|
+
const actualWriteFile = fs.writeFile.bind(fs);
|
|
153
|
+
const writeFileSpy = vi
|
|
154
|
+
.spyOn(fs, 'writeFile')
|
|
155
|
+
.mockImplementation(async (file, data, options) => {
|
|
156
|
+
if (
|
|
157
|
+
typeof file === 'string' &&
|
|
158
|
+
file.startsWith(ownerFilePath) &&
|
|
159
|
+
file !== ownerFilePath
|
|
160
|
+
) {
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return await actualWriteFile(file, data, options);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const lease = await manager.acquire(key);
|
|
169
|
+
const initialOwner = JSON.parse(
|
|
170
|
+
await fs.readFile(ownerFilePath, 'utf8'),
|
|
171
|
+
) as ResourceLockOwner;
|
|
172
|
+
|
|
173
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
174
|
+
|
|
175
|
+
for (let index = 0; index < 5; index += 1) {
|
|
176
|
+
const owner = JSON.parse(
|
|
177
|
+
await fs.readFile(ownerFilePath, 'utf8'),
|
|
178
|
+
) as ResourceLockOwner;
|
|
179
|
+
expect(owner.ticketId).toBe(initialOwner.ticketId);
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await lease.release();
|
|
184
|
+
} finally {
|
|
185
|
+
writeFileSpy.mockRestore();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
type ResourceLockOwner = {
|
|
191
|
+
ticketId: string;
|
|
192
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
type HarnessPlugin,
|
|
4
|
+
} from '@react-native-harness/plugins';
|
|
5
|
+
import type { Config as HarnessConfig } from '@react-native-harness/config';
|
|
6
|
+
import type { HarnessPlatform } from '@react-native-harness/platforms';
|
|
7
|
+
import { spawn } from '@react-native-harness/tools';
|
|
8
|
+
|
|
9
|
+
type ActionHookState = {
|
|
10
|
+
_unused?: never;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const getInlineHookScript = (
|
|
14
|
+
name: 'PRE_RUN_HOOK' | 'AFTER_RUN_HOOK'
|
|
15
|
+
): string | null => {
|
|
16
|
+
const value = process.env[name]?.trim();
|
|
17
|
+
|
|
18
|
+
return value ? value : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const runInlineHook = async (script: string): Promise<void> => {
|
|
22
|
+
const env = Object.fromEntries(
|
|
23
|
+
Object.entries(process.env).filter(
|
|
24
|
+
(entry): entry is [string, string] => entry[1] != null
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
await spawn('bash', ['-lc', script], {
|
|
29
|
+
env,
|
|
30
|
+
cwd: process.env.HARNESS_PROJECT_ROOT,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const createActionHooksPlugin = (): HarnessPlugin<
|
|
35
|
+
ActionHookState,
|
|
36
|
+
HarnessConfig,
|
|
37
|
+
HarnessPlatform
|
|
38
|
+
> =>
|
|
39
|
+
definePlugin({
|
|
40
|
+
name: 'github-action-hooks',
|
|
41
|
+
createState: () => ({}),
|
|
42
|
+
hooks: {
|
|
43
|
+
harness: {
|
|
44
|
+
beforeRun: async () => {
|
|
45
|
+
const script = getInlineHookScript('PRE_RUN_HOOK');
|
|
46
|
+
|
|
47
|
+
if (!script) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await runInlineHook(script);
|
|
52
|
+
},
|
|
53
|
+
afterRun: async (ctx) => {
|
|
54
|
+
const script = getInlineHookScript('AFTER_RUN_HOOK');
|
|
55
|
+
|
|
56
|
+
if (!script) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
process.env.HARNESS_EXIT_CODE = ctx.status === 'passed' ? '0' : '1';
|
|
61
|
+
|
|
62
|
+
await runInlineHook(script);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -27,6 +27,28 @@ export class InitializationTimeoutError extends HarnessError {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export class PlatformReadyTimeoutError extends HarnessError {
|
|
31
|
+
constructor(public readonly timeout: number) {
|
|
32
|
+
super(
|
|
33
|
+
`The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.`
|
|
34
|
+
);
|
|
35
|
+
this.name = 'PlatformReadyTimeoutError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class MetroPortRangeExhaustedError extends HarnessError {
|
|
40
|
+
constructor(
|
|
41
|
+
public readonly initialPort: number,
|
|
42
|
+
public readonly attempts: number
|
|
43
|
+
) {
|
|
44
|
+
const finalPort = initialPort + attempts - 1;
|
|
45
|
+
super(
|
|
46
|
+
`Harness could not find an available Metro port in the range ${initialPort}-${finalPort}.`
|
|
47
|
+
);
|
|
48
|
+
this.name = 'MetroPortRangeExhaustedError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
export type NativeCrashPhase = 'startup' | 'execution';
|
|
31
53
|
|
|
32
54
|
export type NativeCrashDetails = AppCrashDetails & {
|
|
@@ -51,10 +73,7 @@ const buildNativeCrashMessage = ({
|
|
|
51
73
|
const hasCrashBlock = summary?.includes('\n') ?? false;
|
|
52
74
|
const shouldRenderSummary =
|
|
53
75
|
Boolean(summary) &&
|
|
54
|
-
!(
|
|
55
|
-
!hasCrashBlock &&
|
|
56
|
-
artifactType === 'ios-crash-report'
|
|
57
|
-
);
|
|
76
|
+
!(!hasCrashBlock && artifactType === 'ios-crash-report');
|
|
58
77
|
|
|
59
78
|
if (shouldRenderSummary && summary) {
|
|
60
79
|
lines.push('');
|