react-native-coverage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Coverage.podspec +20 -0
- package/LICENSE +202 -0
- package/README.md +125 -0
- package/android/build.gradle +51 -0
- package/android/rn-coverage-jacoco.gradle +132 -0
- package/android/rn-coverage.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/coverage/CoverageModule.kt +53 -0
- package/android/src/main/java/com/coverage/CoveragePackage.kt +35 -0
- package/app.plugin.js +1 -0
- package/bin/rn-coverage.js +11 -0
- package/cocoapods/coverage_post_install.rb +197 -0
- package/docs/cli.md +51 -0
- package/docs/config.md +27 -0
- package/docs/index.md +16 -0
- package/docs/integration/android.md +83 -0
- package/docs/integration/ci-appium.md +41 -0
- package/docs/integration/e2e-timing.md +20 -0
- package/docs/integration/ios.md +77 -0
- package/docs/integration/js.md +38 -0
- package/docs/pattern-c.md +5 -0
- package/docs/releasing.md +88 -0
- package/docs.json +24 -0
- package/ios/Coverage.h +5 -0
- package/ios/Coverage.mm +47 -0
- package/ios/CoverageConfig.h +21 -0
- package/ios/CoverageProfile.h +22 -0
- package/ios/CoverageProfile.mm +358 -0
- package/lib/module/NativeCoverage.js +10 -0
- package/lib/module/NativeCoverage.js.map +1 -0
- package/lib/module/assert-coverage.js +181 -0
- package/lib/module/assert-coverage.js.map +1 -0
- package/lib/module/cli/index.js +229 -0
- package/lib/module/cli/index.js.map +1 -0
- package/lib/module/config.js +83 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/exit-codes.js +22 -0
- package/lib/module/exit-codes.js.map +1 -0
- package/lib/module/index.js +35 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/js-coverage.js +172 -0
- package/lib/module/js-coverage.js.map +1 -0
- package/lib/module/node.js +15 -0
- package/lib/module/node.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/path-rewrite.js +44 -0
- package/lib/module/path-rewrite.js.map +1 -0
- package/lib/module/process-ios-native-coverage.js +258 -0
- package/lib/module/process-ios-native-coverage.js.map +1 -0
- package/lib/module/pull-native-coverage.js +210 -0
- package/lib/module/pull-native-coverage.js.map +1 -0
- package/lib/typescript/example-dynamic/App.d.ts +6 -0
- package/lib/typescript/example-dynamic/App.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeCoverage.d.ts +16 -0
- package/lib/typescript/src/NativeCoverage.d.ts.map +1 -0
- package/lib/typescript/src/assert-coverage.d.ts +53 -0
- package/lib/typescript/src/assert-coverage.d.ts.map +1 -0
- package/lib/typescript/src/cli/index.d.ts +4 -0
- package/lib/typescript/src/cli/index.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +93 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/exit-codes.d.ts +16 -0
- package/lib/typescript/src/exit-codes.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +16 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/js-coverage.d.ts +36 -0
- package/lib/typescript/src/js-coverage.d.ts.map +1 -0
- package/lib/typescript/src/node.d.ts +14 -0
- package/lib/typescript/src/node.d.ts.map +1 -0
- package/lib/typescript/src/path-rewrite.d.ts +12 -0
- package/lib/typescript/src/path-rewrite.d.ts.map +1 -0
- package/lib/typescript/src/process-ios-native-coverage.d.ts +61 -0
- package/lib/typescript/src/process-ios-native-coverage.d.ts.map +1 -0
- package/lib/typescript/src/pull-native-coverage.d.ts +38 -0
- package/lib/typescript/src/pull-native-coverage.d.ts.map +1 -0
- package/package.json +184 -0
- package/plugin/build/index.d.ts +21 -0
- package/plugin/build/index.js +153 -0
- package/react-native-coverage.config.js.example +47 -0
- package/react-native.config.js +9 -0
- package/src/NativeCoverage.ts +16 -0
- package/src/assert-coverage.ts +260 -0
- package/src/cli/index.ts +420 -0
- package/src/config.ts +189 -0
- package/src/exit-codes.ts +20 -0
- package/src/index.tsx +35 -0
- package/src/js-coverage.ts +213 -0
- package/src/node.ts +52 -0
- package/src/path-rewrite.ts +52 -0
- package/src/process-ios-native-coverage.ts +425 -0
- package/src/pull-native-coverage.ts +269 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
|
|
6
|
+
import { assertIosLcov } from './assert-coverage';
|
|
7
|
+
import type { CoverageConfig, SourcePathRewriteRule } from './config';
|
|
8
|
+
import { DEFAULT_COVERAGE_CONFIG } from './config';
|
|
9
|
+
import { EXIT_OK, EXIT_STRICT_EMPTY, StrictEmptyError } from './exit-codes';
|
|
10
|
+
import { normalizeSourcePath } from './path-rewrite';
|
|
11
|
+
|
|
12
|
+
export type IosExportOptions = {
|
|
13
|
+
derivedData: string;
|
|
14
|
+
configuration: string;
|
|
15
|
+
appName: string;
|
|
16
|
+
output: string;
|
|
17
|
+
config?: CoverageConfig;
|
|
18
|
+
/** When true, delete processed `.profraw` files after a successful export. */
|
|
19
|
+
deleteProfraw?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* When true (default), run LCOV presence assert after export.
|
|
22
|
+
* Soft mode (`config.strict === false`) warns; strict throws for CLI exit 2.
|
|
23
|
+
*/
|
|
24
|
+
assertAfterExport?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type IosCoverageContext = {
|
|
28
|
+
productsDir: string;
|
|
29
|
+
appBinary: string;
|
|
30
|
+
coverageObjects: string[];
|
|
31
|
+
profrawFiles: string[];
|
|
32
|
+
profileDataDir: string;
|
|
33
|
+
simulatorCoverageDir: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function walkFiles(
|
|
37
|
+
dir: string,
|
|
38
|
+
matcher: (filePath: string) => boolean,
|
|
39
|
+
results: string[] = []
|
|
40
|
+
): string[] {
|
|
41
|
+
if (!fs.existsSync(dir)) {
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
const fullPath = path.join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
walkFiles(fullPath, matcher, results);
|
|
49
|
+
} else if (matcher(fullPath)) {
|
|
50
|
+
results.push(fullPath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collect app binary + matching embedded frameworks for llvm-cov `-object`.
|
|
59
|
+
* Framework matching uses `config.ios.frameworkNamePrefixes` (neutral; empty by default).
|
|
60
|
+
*/
|
|
61
|
+
export function collectCoverageObjects(
|
|
62
|
+
productsDir: string,
|
|
63
|
+
appName: string,
|
|
64
|
+
frameworkNamePrefixes: string[] = []
|
|
65
|
+
): string[] {
|
|
66
|
+
const objects: string[] = [];
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
|
|
69
|
+
const addObject = (candidate: string | undefined) => {
|
|
70
|
+
if (!candidate || seen.has(candidate) || !fs.existsSync(candidate)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
seen.add(candidate);
|
|
74
|
+
objects.push(candidate);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
addObject(path.join(productsDir, `${appName}.app`, appName));
|
|
78
|
+
|
|
79
|
+
// Expo / Xcode companion image: LLVM counters for statically merged pods often
|
|
80
|
+
// live in AppName.debug.dylib (MH_DYLIB) rather than the MH_EXECUTE binary.
|
|
81
|
+
addObject(path.join(productsDir, `${appName}.app`, `${appName}.debug.dylib`));
|
|
82
|
+
|
|
83
|
+
const matchesPrefix = (name: string) =>
|
|
84
|
+
frameworkNamePrefixes.length === 0
|
|
85
|
+
? false
|
|
86
|
+
: frameworkNamePrefixes.some((prefix) => name.startsWith(prefix));
|
|
87
|
+
|
|
88
|
+
const embeddedFrameworksDir = path.join(
|
|
89
|
+
productsDir,
|
|
90
|
+
`${appName}.app`,
|
|
91
|
+
'Frameworks'
|
|
92
|
+
);
|
|
93
|
+
if (fs.existsSync(embeddedFrameworksDir)) {
|
|
94
|
+
for (const entry of fs.readdirSync(embeddedFrameworksDir)) {
|
|
95
|
+
if (!entry.endsWith('.framework') || !matchesPrefix(entry)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const frameworkName = entry.slice(0, -'.framework'.length);
|
|
99
|
+
addObject(path.join(embeddedFrameworksDir, entry, frameworkName));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (objects.length <= 1 && fs.existsSync(productsDir)) {
|
|
104
|
+
for (const entry of fs.readdirSync(productsDir, { withFileTypes: true })) {
|
|
105
|
+
if (!entry.isDirectory() || !matchesPrefix(entry.name)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const frameworkName = entry.name;
|
|
109
|
+
addObject(
|
|
110
|
+
path.join(
|
|
111
|
+
productsDir,
|
|
112
|
+
frameworkName,
|
|
113
|
+
`${frameworkName}.framework`,
|
|
114
|
+
frameworkName
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return objects;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveIosCoverageContext(
|
|
124
|
+
derivedData: string,
|
|
125
|
+
configuration: string,
|
|
126
|
+
appName: string,
|
|
127
|
+
frameworkNamePrefixes: string[] = []
|
|
128
|
+
): IosCoverageContext {
|
|
129
|
+
const productsDir = path.join(
|
|
130
|
+
derivedData,
|
|
131
|
+
'Build/Products',
|
|
132
|
+
`${configuration}-iphonesimulator`
|
|
133
|
+
);
|
|
134
|
+
const appBinary = path.join(productsDir, `${appName}.app`, appName);
|
|
135
|
+
const profileDataDir = path.join(derivedData, 'Build/ProfileData');
|
|
136
|
+
const simulatorCoverageDir = path.join(derivedData, 'output/coverage');
|
|
137
|
+
|
|
138
|
+
const profrawFiles = [
|
|
139
|
+
...walkFiles(simulatorCoverageDir, (filePath) =>
|
|
140
|
+
filePath.endsWith('.profraw')
|
|
141
|
+
),
|
|
142
|
+
...walkFiles(profileDataDir, (filePath) => filePath.endsWith('.profraw')),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const coverageObjects = collectCoverageObjects(
|
|
146
|
+
productsDir,
|
|
147
|
+
appName,
|
|
148
|
+
frameworkNamePrefixes
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
productsDir,
|
|
153
|
+
appBinary,
|
|
154
|
+
coverageObjects,
|
|
155
|
+
profrawFiles,
|
|
156
|
+
profileDataDir,
|
|
157
|
+
simulatorCoverageDir,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function rewriteLcovFile(
|
|
162
|
+
inputPath: string,
|
|
163
|
+
outputPath: string,
|
|
164
|
+
rules: SourcePathRewriteRule[]
|
|
165
|
+
): Promise<{ sourceFileCount: number }> {
|
|
166
|
+
const input = fs.createReadStream(inputPath, { encoding: 'utf8' });
|
|
167
|
+
const output = fs.createWriteStream(outputPath, { encoding: 'utf8' });
|
|
168
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
169
|
+
|
|
170
|
+
let sourceFileCount = 0;
|
|
171
|
+
|
|
172
|
+
for await (const line of lines) {
|
|
173
|
+
if (line.startsWith('SF:')) {
|
|
174
|
+
sourceFileCount += 1;
|
|
175
|
+
const normalizedPath = normalizeSourcePath(line.slice(3), rules);
|
|
176
|
+
output.write(`SF:${normalizedPath}\n`);
|
|
177
|
+
} else {
|
|
178
|
+
output.write(`${line}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await new Promise<void>((resolve, reject) => {
|
|
183
|
+
output.end(() => resolve());
|
|
184
|
+
output.on('error', reject);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { sourceFileCount };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function runOrThrow(command: string, args: string[]): string {
|
|
191
|
+
try {
|
|
192
|
+
return execFileSync(command, args, {
|
|
193
|
+
encoding: 'utf8',
|
|
194
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const err = error as { stderr?: Buffer; stdout?: Buffer; message: string };
|
|
198
|
+
const stderr = err.stderr ? err.stderr.toString() : '';
|
|
199
|
+
const stdout = err.stdout ? err.stdout.toString() : '';
|
|
200
|
+
throw new Error(
|
|
201
|
+
`${command} ${args.join(' ')} failed:\n${stderr || stdout || err.message}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function runToFileOrThrow(
|
|
207
|
+
command: string,
|
|
208
|
+
args: string[],
|
|
209
|
+
outputPath: string
|
|
210
|
+
): void {
|
|
211
|
+
try {
|
|
212
|
+
execFileSync(command, args, {
|
|
213
|
+
stdio: ['ignore', fs.openSync(outputPath, 'w'), 'pipe'],
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const err = error as { stderr?: Buffer; message: string };
|
|
217
|
+
const stderr = err.stderr ? err.stderr.toString() : '';
|
|
218
|
+
throw new Error(
|
|
219
|
+
`${command} ${args.join(' ')} failed:\n${stderr || err.message}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildObjectArgs(coverageObjects: string[]): string[] {
|
|
225
|
+
const args: string[] = [];
|
|
226
|
+
for (const objectPath of coverageObjects) {
|
|
227
|
+
args.push('-object', objectPath);
|
|
228
|
+
}
|
|
229
|
+
return args;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Merge profraw → profdata → LCOV with path rewrite + optional presence assert.
|
|
234
|
+
*/
|
|
235
|
+
export async function exportIosLcov(
|
|
236
|
+
options: IosExportOptions
|
|
237
|
+
): Promise<string> {
|
|
238
|
+
const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
239
|
+
const ctx = resolveIosCoverageContext(
|
|
240
|
+
options.derivedData,
|
|
241
|
+
options.configuration,
|
|
242
|
+
options.appName,
|
|
243
|
+
config.ios.frameworkNamePrefixes
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (ctx.profrawFiles.length === 0) {
|
|
247
|
+
const message = `No .profraw files under ${ctx.simulatorCoverageDir} or ${ctx.profileDataDir}.`;
|
|
248
|
+
if (config.strict) {
|
|
249
|
+
throw new StrictEmptyError(message);
|
|
250
|
+
}
|
|
251
|
+
console.warn(`[rn-coverage] ${message} (soft; continuing)`);
|
|
252
|
+
return options.output;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!fs.existsSync(ctx.appBinary)) {
|
|
256
|
+
throw new Error(`App binary not found at ${ctx.appBinary}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (ctx.coverageObjects.length === 0) {
|
|
260
|
+
throw new Error(`No coverage objects found under ${ctx.productsDir}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
|
264
|
+
|
|
265
|
+
const profdataPath = path.join(path.dirname(options.output), 'profdata');
|
|
266
|
+
runOrThrow('xcrun', [
|
|
267
|
+
'llvm-profdata',
|
|
268
|
+
'merge',
|
|
269
|
+
'-sparse',
|
|
270
|
+
...ctx.profrawFiles,
|
|
271
|
+
'-o',
|
|
272
|
+
profdataPath,
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
const rawLcovPath = path.join(path.dirname(options.output), 'lcov.raw');
|
|
276
|
+
try {
|
|
277
|
+
const exportArgs = [
|
|
278
|
+
'llvm-cov',
|
|
279
|
+
'export',
|
|
280
|
+
'-instr-profile',
|
|
281
|
+
profdataPath,
|
|
282
|
+
...buildObjectArgs(ctx.coverageObjects),
|
|
283
|
+
'-format=lcov',
|
|
284
|
+
];
|
|
285
|
+
runToFileOrThrow('xcrun', exportArgs, rawLcovPath);
|
|
286
|
+
|
|
287
|
+
const { sourceFileCount } = await rewriteLcovFile(
|
|
288
|
+
rawLcovPath,
|
|
289
|
+
options.output,
|
|
290
|
+
config.sourcePathRewrite
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
console.log(
|
|
294
|
+
`[rn-coverage] Wrote ${options.output} (${sourceFileCount} source file(s))`
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const shouldAssert = options.assertAfterExport ?? true;
|
|
298
|
+
if (shouldAssert) {
|
|
299
|
+
const result = assertIosLcov(options.output, config.strict, config);
|
|
300
|
+
if (result.code === EXIT_OK) {
|
|
301
|
+
console.log(`[rn-coverage] ${result.message}`);
|
|
302
|
+
} else if (result.code === EXIT_STRICT_EMPTY) {
|
|
303
|
+
throw new StrictEmptyError(result.message);
|
|
304
|
+
} else {
|
|
305
|
+
console.warn(`[rn-coverage] ${result.message}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (options.deleteProfraw !== false) {
|
|
310
|
+
ctx.profrawFiles.forEach((profrawPath) => {
|
|
311
|
+
fs.rmSync(profrawPath, { force: true });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
fs.rmSync(rawLcovPath, { force: true });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return options.output;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type IosReportOptions = {
|
|
322
|
+
derivedData: string;
|
|
323
|
+
configuration?: string;
|
|
324
|
+
appName?: string;
|
|
325
|
+
profdata?: string;
|
|
326
|
+
outputDir?: string;
|
|
327
|
+
config?: CoverageConfig;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* HTML report via `llvm-cov show -format=html`.
|
|
332
|
+
*/
|
|
333
|
+
export function reportIosHtml(options: IosReportOptions): string {
|
|
334
|
+
const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
335
|
+
const configuration = options.configuration ?? 'Debug';
|
|
336
|
+
const appName = options.appName ?? config.app.iosProductName;
|
|
337
|
+
const ctx = resolveIosCoverageContext(
|
|
338
|
+
options.derivedData,
|
|
339
|
+
configuration,
|
|
340
|
+
appName,
|
|
341
|
+
config.ios.frameworkNamePrefixes
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const profdataPath =
|
|
345
|
+
options.profdata ?? path.resolve(process.cwd(), 'coverage/ios/profdata');
|
|
346
|
+
const outputDir =
|
|
347
|
+
options.outputDir ?? path.resolve(process.cwd(), 'coverage/ios/html');
|
|
348
|
+
|
|
349
|
+
if (!fs.existsSync(profdataPath)) {
|
|
350
|
+
const message = `profdata not found at ${profdataPath} (run ios export first)`;
|
|
351
|
+
if (config.strict) {
|
|
352
|
+
throw new StrictEmptyError(message);
|
|
353
|
+
}
|
|
354
|
+
console.warn(`[rn-coverage] ${message}`);
|
|
355
|
+
return outputDir;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (ctx.coverageObjects.length === 0) {
|
|
359
|
+
throw new Error(`No coverage objects found under ${ctx.productsDir}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
363
|
+
runOrThrow('xcrun', [
|
|
364
|
+
'llvm-cov',
|
|
365
|
+
'show',
|
|
366
|
+
'-format=html',
|
|
367
|
+
`-output-dir=${outputDir}`,
|
|
368
|
+
'-instr-profile',
|
|
369
|
+
profdataPath,
|
|
370
|
+
...buildObjectArgs(ctx.coverageObjects),
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
console.log(`[rn-coverage] Wrote HTML report to ${outputDir}`);
|
|
374
|
+
return outputDir;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export type IosSummaryOptions = {
|
|
378
|
+
derivedData: string;
|
|
379
|
+
configuration?: string;
|
|
380
|
+
appName?: string;
|
|
381
|
+
profdata?: string;
|
|
382
|
+
config?: CoverageConfig;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Terminal summary via `llvm-cov report`.
|
|
387
|
+
*/
|
|
388
|
+
export function summarizeIos(options: IosSummaryOptions): string {
|
|
389
|
+
const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
390
|
+
const configuration = options.configuration ?? 'Debug';
|
|
391
|
+
const appName = options.appName ?? config.app.iosProductName;
|
|
392
|
+
const ctx = resolveIosCoverageContext(
|
|
393
|
+
options.derivedData,
|
|
394
|
+
configuration,
|
|
395
|
+
appName,
|
|
396
|
+
config.ios.frameworkNamePrefixes
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const profdataPath =
|
|
400
|
+
options.profdata ?? path.resolve(process.cwd(), 'coverage/ios/profdata');
|
|
401
|
+
|
|
402
|
+
if (!fs.existsSync(profdataPath)) {
|
|
403
|
+
const message = `profdata not found at ${profdataPath} (run ios export first)`;
|
|
404
|
+
if (config.strict) {
|
|
405
|
+
throw new StrictEmptyError(message);
|
|
406
|
+
}
|
|
407
|
+
console.warn(`[rn-coverage] ${message}`);
|
|
408
|
+
return '';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (ctx.coverageObjects.length === 0) {
|
|
412
|
+
throw new Error(`No coverage objects found under ${ctx.productsDir}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const report = runOrThrow('xcrun', [
|
|
416
|
+
'llvm-cov',
|
|
417
|
+
'report',
|
|
418
|
+
'-instr-profile',
|
|
419
|
+
profdataPath,
|
|
420
|
+
...buildObjectArgs(ctx.coverageObjects),
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
console.log(report);
|
|
424
|
+
return report;
|
|
425
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { assertAndroidJacoco } from './assert-coverage';
|
|
6
|
+
import type { CoverageConfig } from './config';
|
|
7
|
+
import { DEFAULT_COVERAGE_CONFIG } from './config';
|
|
8
|
+
import { EXIT_OK, EXIT_STRICT_EMPTY, StrictEmptyError } from './exit-codes';
|
|
9
|
+
|
|
10
|
+
export type PullOptions = {
|
|
11
|
+
softFail?: boolean;
|
|
12
|
+
outputDir?: string;
|
|
13
|
+
config?: CoverageConfig;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function getAdbBinary(): string {
|
|
17
|
+
return process.env.ANDROID_HOME
|
|
18
|
+
? `${process.env.ANDROID_HOME}/platform-tools/adb`
|
|
19
|
+
: 'adb';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveAndroidDeviceId(preferredDeviceId?: string): string {
|
|
23
|
+
if (preferredDeviceId) {
|
|
24
|
+
return preferredDeviceId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (process.env.ANDROID_SERIAL) {
|
|
28
|
+
return process.env.ANDROID_SERIAL;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const adb = getAdbBinary();
|
|
32
|
+
const output = execSync(`${adb} devices`, { encoding: 'utf8' });
|
|
33
|
+
const deviceLine = output
|
|
34
|
+
.split('\n')
|
|
35
|
+
.slice(1)
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.find((line) => line.endsWith('\tdevice'));
|
|
38
|
+
|
|
39
|
+
if (!deviceLine) {
|
|
40
|
+
throw new Error('No online Android device found for native coverage pull');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return deviceLine.split('\t')[0]!;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function androidCoverageFileExists(
|
|
47
|
+
deviceId: string,
|
|
48
|
+
config: CoverageConfig = DEFAULT_COVERAGE_CONFIG
|
|
49
|
+
): boolean {
|
|
50
|
+
const adb = getAdbBinary();
|
|
51
|
+
const serial = deviceId ? `-s ${deviceId}` : '';
|
|
52
|
+
const appId = config.app.androidApplicationId;
|
|
53
|
+
const relative = config.android.coverageRelativePath;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
execSync(`${adb} ${serial} shell "run-as ${appId} test -f ${relative}"`, {
|
|
57
|
+
stdio: 'pipe',
|
|
58
|
+
});
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pull Android Emma/Jacoco `.ec` from the device into `outputDir`.
|
|
67
|
+
* Ported from RNFB tests/scripts/pull-native-coverage.js with config injection.
|
|
68
|
+
*/
|
|
69
|
+
export function pullAndroidCoverage(
|
|
70
|
+
deviceId: string,
|
|
71
|
+
options: PullOptions = {}
|
|
72
|
+
): string | null {
|
|
73
|
+
const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
74
|
+
const softFail = options.softFail ?? false;
|
|
75
|
+
// Default under app/build so subsequent `jacocoTestReport` finds the .ec
|
|
76
|
+
// (fileTree(project.buildDir)). Artifacts/ dirs alone leave the task SKIPPED.
|
|
77
|
+
const localDestDir =
|
|
78
|
+
options.outputDir ??
|
|
79
|
+
path.resolve(process.cwd(), 'android/app/build/outputs/code_coverage');
|
|
80
|
+
const localDestFile = path.join(localDestDir, 'emulator_coverage.ec');
|
|
81
|
+
const staging = config.android.detoxStagingPath;
|
|
82
|
+
const adb = getAdbBinary();
|
|
83
|
+
const serial = deviceId ? `-s ${deviceId}` : '';
|
|
84
|
+
const appId = config.app.androidApplicationId;
|
|
85
|
+
const relative = config.android.coverageRelativePath;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Staging lives under /data/local/tmp/...; the parent dir is not created by
|
|
89
|
+
// the emulator (unlike Detox's /data/local/tmp/detox). Redirect fails with
|
|
90
|
+
// "No such file or directory" if we skip mkdir.
|
|
91
|
+
const stagingDir = path.posix.dirname(staging);
|
|
92
|
+
execSync(`${adb} ${serial} shell "mkdir -p ${stagingDir}"`);
|
|
93
|
+
execSync(
|
|
94
|
+
`${adb} ${serial} shell "run-as ${appId} cat ${relative} > ${staging}"`
|
|
95
|
+
);
|
|
96
|
+
fs.mkdirSync(localDestDir, { recursive: true });
|
|
97
|
+
execSync(`${adb} ${serial} pull ${staging} ${localDestFile}`);
|
|
98
|
+
|
|
99
|
+
console.log(`Coverage data downloaded to: ${localDestFile}`);
|
|
100
|
+
return localDestFile;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const message = `Android native coverage pull failed: ${(error as Error).message}`;
|
|
103
|
+
if (softFail) {
|
|
104
|
+
console.warn(`[rn-coverage] ${message}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
throw new Error(message);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function pullAndroidCoverageWithRetry(
|
|
112
|
+
deviceId: string,
|
|
113
|
+
options: PullOptions & { retries?: number; intervalMs?: number } = {}
|
|
114
|
+
): Promise<string | null> {
|
|
115
|
+
const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
116
|
+
const softFail = options.softFail ?? !config.strict;
|
|
117
|
+
const retries = options.retries ?? 15;
|
|
118
|
+
const intervalMs = options.intervalMs ?? 2000;
|
|
119
|
+
|
|
120
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
121
|
+
if (androidCoverageFileExists(deviceId, config)) {
|
|
122
|
+
const pulled = pullAndroidCoverage(deviceId, {
|
|
123
|
+
...options,
|
|
124
|
+
softFail: true,
|
|
125
|
+
config,
|
|
126
|
+
});
|
|
127
|
+
if (pulled) {
|
|
128
|
+
return pulled;
|
|
129
|
+
}
|
|
130
|
+
} else if (attempt === 1 || attempt % 5 === 0) {
|
|
131
|
+
console.log(
|
|
132
|
+
`[rn-coverage] Waiting for ${config.android.coverageRelativePath} (attempt ${attempt}/${retries})`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (attempt < retries) {
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const existed = androidCoverageFileExists(deviceId, config);
|
|
142
|
+
const message = existed
|
|
143
|
+
? `Android native coverage pull failed after ${retries} attempts (file present but staging/pull failed)`
|
|
144
|
+
: `Android native coverage file not found after ${retries} attempts`;
|
|
145
|
+
if (softFail) {
|
|
146
|
+
console.warn(`[rn-coverage] ${message}`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
throw new StrictEmptyError(message);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pull iOS LLVM `.profraw` files from a simulator app container.
|
|
154
|
+
* Ported from RNFB with config-driven bundle id / output paths.
|
|
155
|
+
*/
|
|
156
|
+
export function pullIosCoverage(
|
|
157
|
+
deviceId: string,
|
|
158
|
+
options: PullOptions = {}
|
|
159
|
+
): string[] {
|
|
160
|
+
const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
161
|
+
const softFail = options.softFail ?? !config.strict;
|
|
162
|
+
const localDestDir =
|
|
163
|
+
options.outputDir ?? path.resolve(process.cwd(), 'coverage/ios');
|
|
164
|
+
const bundleId = config.app.iosBundleId;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const container = execSync(
|
|
168
|
+
`xcrun simctl get_app_container ${deviceId} ${bundleId} data`,
|
|
169
|
+
{ encoding: 'utf8' }
|
|
170
|
+
).trim();
|
|
171
|
+
fs.mkdirSync(localDestDir, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const profrawList = execSync(
|
|
174
|
+
`find "${container}" \\( -path "*/Documents/coverage.profraw" -o -path "*/tmp/coverage.profraw" -o -name '*.profraw' \\)`,
|
|
175
|
+
{ encoding: 'utf8' }
|
|
176
|
+
)
|
|
177
|
+
.trim()
|
|
178
|
+
.split('\n')
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
|
|
181
|
+
if (profrawList.length === 0) {
|
|
182
|
+
const message = `No iOS coverage profraw files found under ${container}`;
|
|
183
|
+
if (softFail) {
|
|
184
|
+
console.warn(`[rn-coverage] ${message}`);
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
throw new StrictEmptyError(message);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const destPaths = profrawList.map((src, index) => {
|
|
191
|
+
const suffix = profrawList.length > 1 ? `_${index}` : '';
|
|
192
|
+
const dest = path.join(
|
|
193
|
+
localDestDir,
|
|
194
|
+
`simulator_coverage${suffix}.profraw`
|
|
195
|
+
);
|
|
196
|
+
execSync(`cp "${src}" "${dest}"`);
|
|
197
|
+
return dest;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
console.log(
|
|
201
|
+
`Coverage data downloaded to: ${localDestDir} (${profrawList.length} profraw file(s))`
|
|
202
|
+
);
|
|
203
|
+
return destPaths;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof StrictEmptyError) {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const message = `iOS native coverage pull failed: ${(error as Error).message}`;
|
|
209
|
+
if (softFail) {
|
|
210
|
+
console.warn(`[rn-coverage] ${message}`);
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
throw new Error(message);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type AndroidReportOptions = {
|
|
218
|
+
androidDir?: string;
|
|
219
|
+
jacocoXml?: string;
|
|
220
|
+
config?: CoverageConfig;
|
|
221
|
+
/** When true (default), assert Jacoco XML after a successful Gradle report. */
|
|
222
|
+
assertAfterReport?: boolean;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run a consumer Jacoco report Gradle task, then optionally assert LINE hits.
|
|
227
|
+
*/
|
|
228
|
+
export function runJacocoTestReport(
|
|
229
|
+
options: AndroidReportOptions | string = {}
|
|
230
|
+
): { ok: boolean; exitCode: number } {
|
|
231
|
+
const normalized: AndroidReportOptions =
|
|
232
|
+
typeof options === 'string' ? { androidDir: options } : options;
|
|
233
|
+
const config = normalized.config ?? DEFAULT_COVERAGE_CONFIG;
|
|
234
|
+
const androidDir =
|
|
235
|
+
normalized.androidDir ?? path.resolve(process.cwd(), 'android');
|
|
236
|
+
|
|
237
|
+
const result = spawnSync('./gradlew', ['jacocoTestReport'], {
|
|
238
|
+
cwd: androidDir,
|
|
239
|
+
stdio: 'inherit',
|
|
240
|
+
shell: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (result.status !== 0) {
|
|
244
|
+
console.warn(
|
|
245
|
+
`[rn-coverage] jacocoTestReport exited with status ${result.status ?? 'unknown'}`
|
|
246
|
+
);
|
|
247
|
+
return { ok: false, exitCode: result.status ?? 1 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const shouldAssert = normalized.assertAfterReport ?? true;
|
|
251
|
+
if (!shouldAssert) {
|
|
252
|
+
return { ok: true, exitCode: EXIT_OK };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const jacocoXml =
|
|
256
|
+
normalized.jacocoXml ??
|
|
257
|
+
path.resolve(process.cwd(), config.android.jacocoReportXml);
|
|
258
|
+
const assertResult = assertAndroidJacoco(jacocoXml, config.strict, config);
|
|
259
|
+
if (assertResult.code === EXIT_OK) {
|
|
260
|
+
console.log(`[rn-coverage] ${assertResult.message}`);
|
|
261
|
+
return { ok: true, exitCode: EXIT_OK };
|
|
262
|
+
}
|
|
263
|
+
if (assertResult.code === EXIT_STRICT_EMPTY) {
|
|
264
|
+
console.error(`[rn-coverage] ${assertResult.message}`);
|
|
265
|
+
return { ok: false, exitCode: EXIT_STRICT_EMPTY };
|
|
266
|
+
}
|
|
267
|
+
console.warn(`[rn-coverage] ${assertResult.message}`);
|
|
268
|
+
return { ok: true, exitCode: EXIT_OK };
|
|
269
|
+
}
|