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.
Files changed (92) hide show
  1. package/Coverage.podspec +20 -0
  2. package/LICENSE +202 -0
  3. package/README.md +125 -0
  4. package/android/build.gradle +51 -0
  5. package/android/rn-coverage-jacoco.gradle +132 -0
  6. package/android/rn-coverage.gradle +68 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/coverage/CoverageModule.kt +53 -0
  9. package/android/src/main/java/com/coverage/CoveragePackage.kt +35 -0
  10. package/app.plugin.js +1 -0
  11. package/bin/rn-coverage.js +11 -0
  12. package/cocoapods/coverage_post_install.rb +197 -0
  13. package/docs/cli.md +51 -0
  14. package/docs/config.md +27 -0
  15. package/docs/index.md +16 -0
  16. package/docs/integration/android.md +83 -0
  17. package/docs/integration/ci-appium.md +41 -0
  18. package/docs/integration/e2e-timing.md +20 -0
  19. package/docs/integration/ios.md +77 -0
  20. package/docs/integration/js.md +38 -0
  21. package/docs/pattern-c.md +5 -0
  22. package/docs/releasing.md +88 -0
  23. package/docs.json +24 -0
  24. package/ios/Coverage.h +5 -0
  25. package/ios/Coverage.mm +47 -0
  26. package/ios/CoverageConfig.h +21 -0
  27. package/ios/CoverageProfile.h +22 -0
  28. package/ios/CoverageProfile.mm +358 -0
  29. package/lib/module/NativeCoverage.js +10 -0
  30. package/lib/module/NativeCoverage.js.map +1 -0
  31. package/lib/module/assert-coverage.js +181 -0
  32. package/lib/module/assert-coverage.js.map +1 -0
  33. package/lib/module/cli/index.js +229 -0
  34. package/lib/module/cli/index.js.map +1 -0
  35. package/lib/module/config.js +83 -0
  36. package/lib/module/config.js.map +1 -0
  37. package/lib/module/exit-codes.js +22 -0
  38. package/lib/module/exit-codes.js.map +1 -0
  39. package/lib/module/index.js +35 -0
  40. package/lib/module/index.js.map +1 -0
  41. package/lib/module/js-coverage.js +172 -0
  42. package/lib/module/js-coverage.js.map +1 -0
  43. package/lib/module/node.js +15 -0
  44. package/lib/module/node.js.map +1 -0
  45. package/lib/module/package.json +1 -0
  46. package/lib/module/path-rewrite.js +44 -0
  47. package/lib/module/path-rewrite.js.map +1 -0
  48. package/lib/module/process-ios-native-coverage.js +258 -0
  49. package/lib/module/process-ios-native-coverage.js.map +1 -0
  50. package/lib/module/pull-native-coverage.js +210 -0
  51. package/lib/module/pull-native-coverage.js.map +1 -0
  52. package/lib/typescript/example-dynamic/App.d.ts +6 -0
  53. package/lib/typescript/example-dynamic/App.d.ts.map +1 -0
  54. package/lib/typescript/package.json +1 -0
  55. package/lib/typescript/src/NativeCoverage.d.ts +16 -0
  56. package/lib/typescript/src/NativeCoverage.d.ts.map +1 -0
  57. package/lib/typescript/src/assert-coverage.d.ts +53 -0
  58. package/lib/typescript/src/assert-coverage.d.ts.map +1 -0
  59. package/lib/typescript/src/cli/index.d.ts +4 -0
  60. package/lib/typescript/src/cli/index.d.ts.map +1 -0
  61. package/lib/typescript/src/config.d.ts +93 -0
  62. package/lib/typescript/src/config.d.ts.map +1 -0
  63. package/lib/typescript/src/exit-codes.d.ts +16 -0
  64. package/lib/typescript/src/exit-codes.d.ts.map +1 -0
  65. package/lib/typescript/src/index.d.ts +16 -0
  66. package/lib/typescript/src/index.d.ts.map +1 -0
  67. package/lib/typescript/src/js-coverage.d.ts +36 -0
  68. package/lib/typescript/src/js-coverage.d.ts.map +1 -0
  69. package/lib/typescript/src/node.d.ts +14 -0
  70. package/lib/typescript/src/node.d.ts.map +1 -0
  71. package/lib/typescript/src/path-rewrite.d.ts +12 -0
  72. package/lib/typescript/src/path-rewrite.d.ts.map +1 -0
  73. package/lib/typescript/src/process-ios-native-coverage.d.ts +61 -0
  74. package/lib/typescript/src/process-ios-native-coverage.d.ts.map +1 -0
  75. package/lib/typescript/src/pull-native-coverage.d.ts +38 -0
  76. package/lib/typescript/src/pull-native-coverage.d.ts.map +1 -0
  77. package/package.json +184 -0
  78. package/plugin/build/index.d.ts +21 -0
  79. package/plugin/build/index.js +153 -0
  80. package/react-native-coverage.config.js.example +47 -0
  81. package/react-native.config.js +9 -0
  82. package/src/NativeCoverage.ts +16 -0
  83. package/src/assert-coverage.ts +260 -0
  84. package/src/cli/index.ts +420 -0
  85. package/src/config.ts +189 -0
  86. package/src/exit-codes.ts +20 -0
  87. package/src/index.tsx +35 -0
  88. package/src/js-coverage.ts +213 -0
  89. package/src/node.ts +52 -0
  90. package/src/path-rewrite.ts +52 -0
  91. package/src/process-ios-native-coverage.ts +425 -0
  92. package/src/pull-native-coverage.ts +269 -0
@@ -0,0 +1,260 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { CoverageConfig } from './config';
5
+ import { DEFAULT_COVERAGE_CONFIG } from './config';
6
+ import { EXIT_OK, EXIT_STRICT_EMPTY } from './exit-codes';
7
+
8
+ export type AssertResult = {
9
+ code: number;
10
+ message: string;
11
+ };
12
+
13
+ function failEmpty(strict: boolean, message: string): AssertResult {
14
+ if (strict) {
15
+ return { code: EXIT_STRICT_EMPTY, message };
16
+ }
17
+ return { code: EXIT_OK, message: `(soft) ${message}` };
18
+ }
19
+
20
+ export type LcovAssertStats = {
21
+ sourceFileCount: number;
22
+ matchedPathHits: number;
23
+ linesHit: number;
24
+ };
25
+
26
+ /**
27
+ * Parse LCOV and count SF: paths matching configured includes.
28
+ * When `lcovPathIncludes` is empty, any SF: path counts as a match.
29
+ * `linesHit` sums `LH:` only for matched source files.
30
+ */
31
+ export function analyzeLcov(
32
+ lcovPath: string,
33
+ lcovPathIncludes: string[] = []
34
+ ): LcovAssertStats {
35
+ const text = fs.readFileSync(lcovPath, 'utf8');
36
+ let sourceFileCount = 0;
37
+ let matchedPathHits = 0;
38
+ let linesHit = 0;
39
+ let currentMatched = false;
40
+
41
+ for (const line of text.split('\n')) {
42
+ if (line.startsWith('SF:')) {
43
+ sourceFileCount += 1;
44
+ const sf = line.slice(3).replace(/\\/g, '/');
45
+ currentMatched =
46
+ lcovPathIncludes.length === 0 ||
47
+ lcovPathIncludes.some(
48
+ (include) => sf.includes(include) || sf.startsWith(include)
49
+ );
50
+ if (currentMatched) {
51
+ matchedPathHits += 1;
52
+ }
53
+ } else if (line.startsWith('LH:') && currentMatched) {
54
+ linesHit += Number(line.slice(3)) || 0;
55
+ } else if (line.startsWith('end_of_record')) {
56
+ currentMatched = false;
57
+ }
58
+ }
59
+
60
+ return { sourceFileCount, matchedPathHits, linesHit };
61
+ }
62
+
63
+ /**
64
+ * Assert iOS (or any) LCOV has expected path hits with actual line coverage.
65
+ * Exit 2 in strict mode when missing/empty/no matched SF: paths/no LH hits.
66
+ */
67
+ export function assertIosLcov(
68
+ lcovPath: string,
69
+ strict: boolean,
70
+ config: CoverageConfig = DEFAULT_COVERAGE_CONFIG,
71
+ cwd: string = process.cwd()
72
+ ): AssertResult {
73
+ const relative = path.relative(cwd, lcovPath) || lcovPath;
74
+ const includes = config.assert.lcovPathIncludes;
75
+
76
+ if (!fs.existsSync(lcovPath)) {
77
+ return failEmpty(strict, `iOS LCOV missing: ${relative}`);
78
+ }
79
+
80
+ const stat = fs.statSync(lcovPath);
81
+ if (stat.size === 0) {
82
+ return failEmpty(strict, `iOS LCOV empty (0 bytes): ${relative}`);
83
+ }
84
+
85
+ const { sourceFileCount, matchedPathHits, linesHit } = analyzeLcov(
86
+ lcovPath,
87
+ includes
88
+ );
89
+
90
+ if (matchedPathHits === 0 || linesHit === 0) {
91
+ const includeDesc =
92
+ includes.length > 0 ? includes.join(', ') : '(any SF: path)';
93
+ return failEmpty(
94
+ strict,
95
+ `iOS LCOV has no expected path/line hits [${includeDesc}] (sourceFiles=${sourceFileCount}, matchedPathHits=${matchedPathHits}, linesHit=${linesHit}): ${relative}`
96
+ );
97
+ }
98
+
99
+ return {
100
+ code: EXIT_OK,
101
+ message: `iOS ok: matchedPathHits=${matchedPathHits} sourceFiles=${sourceFileCount} linesHit=${linesHit} (${relative})`,
102
+ };
103
+ }
104
+
105
+ export type JacocoAssertStats = {
106
+ packageCount: number;
107
+ lineCovered: number;
108
+ lineMissed: number;
109
+ };
110
+
111
+ /**
112
+ * Parse Jacoco XML package rollups. Matchers are case-insensitive substrings
113
+ * of `<package name="...">`. Empty matchers → all packages.
114
+ *
115
+ * Jacoco uses `/` separators (`com/coverage/fixture`); configs often use Java
116
+ * dots (`coverage.fixture`). Normalize both so either form matches.
117
+ */
118
+ export function analyzeJacocoXml(
119
+ xmlPath: string,
120
+ packageIncludes: string[] = []
121
+ ): JacocoAssertStats {
122
+ const xml = fs.readFileSync(xmlPath, 'utf8');
123
+ const packageBlocks = [
124
+ ...xml.matchAll(/<package name="([^"]+)"[^>]*>([\s\S]*?)<\/package>/g),
125
+ ];
126
+
127
+ const normalizePkg = (value: string) =>
128
+ value.toLowerCase().replace(/[./]/g, '/');
129
+ const normalizedIncludes = packageIncludes.map(normalizePkg);
130
+
131
+ let packageCount = 0;
132
+ let lineCovered = 0;
133
+ let lineMissed = 0;
134
+
135
+ for (const match of packageBlocks) {
136
+ const name = match[1]!;
137
+ const body = match[2]!;
138
+ const normalizedName = normalizePkg(name);
139
+ if (
140
+ normalizedIncludes.length > 0 &&
141
+ !normalizedIncludes.some((m) => normalizedName.includes(m))
142
+ ) {
143
+ continue;
144
+ }
145
+ packageCount += 1;
146
+
147
+ const trailing = body.match(
148
+ /((?:<counter type="[^"]+" missed="\d+" covered="\d+"\/>\s*)+)$/
149
+ );
150
+ if (!trailing) {
151
+ continue;
152
+ }
153
+ for (const c of trailing[1]!.matchAll(
154
+ /<counter type="(LINE|INSTRUCTION)" missed="(\d+)" covered="(\d+)"\/>/g
155
+ )) {
156
+ if (c[1] === 'LINE') {
157
+ lineMissed += Number(c[2]);
158
+ lineCovered += Number(c[3]);
159
+ }
160
+ }
161
+ }
162
+
163
+ return { packageCount, lineCovered, lineMissed };
164
+ }
165
+
166
+ /**
167
+ * Assert Android Jacoco XML has expected package LINE hits.
168
+ * Exit 2 in strict mode when missing/empty/no covered lines in matched packages.
169
+ */
170
+ export function assertAndroidJacoco(
171
+ xmlPath: string,
172
+ strict: boolean,
173
+ config: CoverageConfig = DEFAULT_COVERAGE_CONFIG,
174
+ cwd: string = process.cwd()
175
+ ): AssertResult {
176
+ const relative = path.relative(cwd, xmlPath) || xmlPath;
177
+ const includes =
178
+ config.assert.jacocoPackageIncludes.length > 0
179
+ ? config.assert.jacocoPackageIncludes
180
+ : config.android.libraryProjectMatchers;
181
+
182
+ if (!fs.existsSync(xmlPath)) {
183
+ return failEmpty(strict, `Android Jacoco XML missing: ${relative}`);
184
+ }
185
+
186
+ const stat = fs.statSync(xmlPath);
187
+ if (stat.size === 0) {
188
+ return failEmpty(strict, `Android Jacoco XML empty (0 bytes): ${relative}`);
189
+ }
190
+
191
+ const { packageCount, lineCovered, lineMissed } = analyzeJacocoXml(
192
+ xmlPath,
193
+ includes
194
+ );
195
+
196
+ if (packageCount === 0 || lineCovered === 0) {
197
+ const includeDesc =
198
+ includes.length > 0 ? includes.join(', ') : '(any package)';
199
+ return failEmpty(
200
+ strict,
201
+ `Android Jacoco has empty package hits [${includeDesc}] (packages=${packageCount}, lineCovered=${lineCovered}, lineMissed=${lineMissed}): ${relative}`
202
+ );
203
+ }
204
+
205
+ return {
206
+ code: EXIT_OK,
207
+ message: `Android ok: packages=${packageCount} lineCovered=${lineCovered} lineMissed=${lineMissed} (${relative})`,
208
+ };
209
+ }
210
+
211
+ export type AssertCoverageOptions = {
212
+ platform?: 'ios' | 'android' | 'all';
213
+ lcov?: string;
214
+ jacocoXml?: string;
215
+ strict?: boolean;
216
+ config?: CoverageConfig;
217
+ cwd?: string;
218
+ };
219
+
220
+ /**
221
+ * Post-pipeline presence check. Returns the highest-priority failure code
222
+ * (prefer EXIT_STRICT_EMPTY over EXIT_OK).
223
+ */
224
+ export function assertCoverage(
225
+ options: AssertCoverageOptions = {}
226
+ ): AssertResult {
227
+ const config = options.config ?? DEFAULT_COVERAGE_CONFIG;
228
+ const strict = options.strict ?? config.strict;
229
+ const cwd = options.cwd ?? process.cwd();
230
+ const platform = options.platform ?? 'all';
231
+
232
+ const lcov = options.lcov ?? path.resolve(cwd, config.assert.defaultLcovPath);
233
+ const jacocoXml =
234
+ options.jacocoXml ?? path.resolve(cwd, config.assert.defaultJacocoXmlPath);
235
+
236
+ const results: AssertResult[] = [];
237
+
238
+ if (platform === 'ios' || platform === 'all') {
239
+ results.push(assertIosLcov(lcov, strict, config, cwd));
240
+ }
241
+ if (platform === 'android' || platform === 'all') {
242
+ results.push(assertAndroidJacoco(jacocoXml, strict, config, cwd));
243
+ }
244
+
245
+ let code = EXIT_OK;
246
+ const messages: string[] = [];
247
+ for (const result of results) {
248
+ messages.push(result.message);
249
+ if (result.code === EXIT_STRICT_EMPTY) {
250
+ code = EXIT_STRICT_EMPTY;
251
+ } else if (result.code !== EXIT_OK && code === EXIT_OK) {
252
+ code = result.code;
253
+ }
254
+ }
255
+
256
+ return {
257
+ code,
258
+ message: messages.join('\n'),
259
+ };
260
+ }
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+
4
+ import { assertCoverage } from '../assert-coverage';
5
+ import type { CoverageConfig } from '../config';
6
+ import { loadCoverageConfig } from '../config';
7
+ import {
8
+ EXIT_ERROR,
9
+ EXIT_OK,
10
+ EXIT_STRICT_EMPTY,
11
+ StrictEmptyError,
12
+ } from '../exit-codes';
13
+ import {
14
+ exportIosLcov,
15
+ reportIosHtml,
16
+ summarizeIos,
17
+ } from '../process-ios-native-coverage';
18
+ import {
19
+ pullAndroidCoverageWithRetry,
20
+ pullIosCoverage,
21
+ resolveAndroidDeviceId,
22
+ runJacocoTestReport,
23
+ } from '../pull-native-coverage';
24
+ import {
25
+ pullAndroidJsCoverage,
26
+ pullIosJsCoverage,
27
+ reportJsCoverage,
28
+ } from '../js-coverage';
29
+
30
+ export { EXIT_OK, EXIT_ERROR, EXIT_STRICT_EMPTY };
31
+
32
+ type RootOpts = {
33
+ config?: string;
34
+ strict?: boolean;
35
+ };
36
+
37
+ function applyStrictOverride(
38
+ config: CoverageConfig,
39
+ rootOpts: RootOpts
40
+ ): CoverageConfig {
41
+ if (rootOpts.strict === true) {
42
+ config.strict = true;
43
+ } else if (rootOpts.strict === false) {
44
+ config.strict = false;
45
+ }
46
+ return config;
47
+ }
48
+
49
+ function rootOptsFrom(cmd: Command): RootOpts {
50
+ // android/ios subcommands: parent.parent is program
51
+ // assert: parent is program
52
+ const parent = cmd.parent;
53
+ if (parent?.parent) {
54
+ return (parent.parent.opts() ?? {}) as RootOpts;
55
+ }
56
+ return (parent?.opts() ?? {}) as RootOpts;
57
+ }
58
+
59
+ function mapErrorToExit(error: unknown): number {
60
+ // Exit 2 only for explicit empty/missing coverage-hit guards.
61
+ // Tooling/config failures (e.g. missing app binary) stay exit 1.
62
+ if (error instanceof StrictEmptyError) {
63
+ return EXIT_STRICT_EMPTY;
64
+ }
65
+ return EXIT_ERROR;
66
+ }
67
+
68
+ async function main(): Promise<void> {
69
+ const program = new Command();
70
+
71
+ program
72
+ .name('rn-coverage')
73
+ .description(
74
+ 'Native coverage CLI for React Native (pull / export / report / summary / assert). Pattern C: dedicated test apps only.'
75
+ )
76
+ .option('-c, --config <path>', 'Path to react-native-coverage.config.js')
77
+ .option('--strict', 'Fail with exit 2 when artifacts/hits are empty')
78
+ .option('--no-strict', 'Warn instead of exit 2 on empty artifacts');
79
+
80
+ program
81
+ .command('android')
82
+ .description('Android coverage commands')
83
+ .addCommand(
84
+ new Command('pull')
85
+ .description('Pull .ec coverage from a connected device/emulator')
86
+ .option('--device <id>', 'adb device serial')
87
+ .option('--output <dir>', 'Local output directory')
88
+ .option('--retries <n>', 'Retry count while waiting for .ec', '15')
89
+ .action(async (opts, cmd) => {
90
+ const rootOpts = rootOptsFrom(cmd);
91
+ const config = applyStrictOverride(
92
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
93
+ rootOpts
94
+ );
95
+ try {
96
+ const deviceId = resolveAndroidDeviceId(opts.device);
97
+ const pulled = await pullAndroidCoverageWithRetry(deviceId, {
98
+ softFail: !config.strict,
99
+ outputDir: opts.output,
100
+ retries: Number(opts.retries) || 15,
101
+ config,
102
+ });
103
+ if (!pulled && config.strict) {
104
+ console.error(
105
+ '[rn-coverage] Android coverage.ec missing (strict)'
106
+ );
107
+ process.exitCode = EXIT_STRICT_EMPTY;
108
+ }
109
+ } catch (error) {
110
+ console.error(`[rn-coverage] ${(error as Error).message}`);
111
+ process.exitCode = mapErrorToExit(error);
112
+ }
113
+ })
114
+ )
115
+ .addCommand(
116
+ new Command('report')
117
+ .description(
118
+ 'Run jacocoTestReport then assert Jacoco XML LINE hits (exit 2 if strict empty)'
119
+ )
120
+ .option('--android-dir <path>', 'Android project directory', 'android')
121
+ .option('--jacoco-xml <path>', 'Jacoco XML path to assert after report')
122
+ .action(async (opts, cmd) => {
123
+ const rootOpts = rootOptsFrom(cmd);
124
+ const config = applyStrictOverride(
125
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
126
+ rootOpts
127
+ );
128
+ const result = runJacocoTestReport({
129
+ androidDir: opts.androidDir,
130
+ jacocoXml: opts.jacocoXml,
131
+ config,
132
+ });
133
+ if (!result.ok) {
134
+ process.exitCode = result.exitCode;
135
+ }
136
+ })
137
+ );
138
+
139
+ program
140
+ .command('ios')
141
+ .description('iOS coverage commands')
142
+ .addCommand(
143
+ new Command('pull')
144
+ .description('Pull .profraw from a simulator app container')
145
+ .requiredOption('--device <udid>', 'Simulator UDID')
146
+ .option('--output <dir>', 'Local output directory')
147
+ .action(async (opts, cmd) => {
148
+ const rootOpts = rootOptsFrom(cmd);
149
+ const config = applyStrictOverride(
150
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
151
+ rootOpts
152
+ );
153
+ try {
154
+ const pulled = pullIosCoverage(opts.device, {
155
+ outputDir: opts.output,
156
+ softFail: !config.strict,
157
+ config,
158
+ });
159
+ if (pulled.length === 0 && config.strict) {
160
+ process.exitCode = EXIT_STRICT_EMPTY;
161
+ }
162
+ } catch (error) {
163
+ console.error(`[rn-coverage] ${(error as Error).message}`);
164
+ process.exitCode = mapErrorToExit(error);
165
+ }
166
+ })
167
+ )
168
+ .addCommand(
169
+ new Command('export')
170
+ .description(
171
+ 'Merge profraw and export LCOV via llvm-cov (asserts path hits; exit 2 if strict empty)'
172
+ )
173
+ .requiredOption('--derived-data <path>', 'Xcode derived data path')
174
+ .option('--configuration <name>', 'Xcode configuration', 'Debug')
175
+ .option('--app-name <name>', 'App product name')
176
+ .option('--output <path>', 'LCOV output path', 'coverage/ios/lcov.info')
177
+ .action(async (opts, cmd) => {
178
+ const rootOpts = rootOptsFrom(cmd);
179
+ const config = applyStrictOverride(
180
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
181
+ rootOpts
182
+ );
183
+ try {
184
+ await exportIosLcov({
185
+ derivedData: opts.derivedData,
186
+ configuration: opts.configuration,
187
+ appName: opts.appName ?? config.app.iosProductName,
188
+ output: opts.output,
189
+ config,
190
+ });
191
+ } catch (error) {
192
+ console.error(`[rn-coverage] ${(error as Error).message}`);
193
+ process.exitCode = mapErrorToExit(error);
194
+ }
195
+ })
196
+ )
197
+ .addCommand(
198
+ new Command('report')
199
+ .description('HTML report via llvm-cov show -format=html')
200
+ .requiredOption('--derived-data <path>', 'Xcode derived data path')
201
+ .option('--configuration <name>', 'Xcode configuration', 'Debug')
202
+ .option('--app-name <name>', 'App product name')
203
+ .option(
204
+ '--profdata <path>',
205
+ 'Merged profdata path',
206
+ 'coverage/ios/profdata'
207
+ )
208
+ .option(
209
+ '--output-dir <path>',
210
+ 'HTML output directory',
211
+ 'coverage/ios/html'
212
+ )
213
+ .action(async (opts, cmd) => {
214
+ const rootOpts = rootOptsFrom(cmd);
215
+ const config = applyStrictOverride(
216
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
217
+ rootOpts
218
+ );
219
+ try {
220
+ reportIosHtml({
221
+ derivedData: opts.derivedData,
222
+ configuration: opts.configuration,
223
+ appName: opts.appName,
224
+ profdata: opts.profdata,
225
+ outputDir: opts.outputDir,
226
+ config,
227
+ });
228
+ } catch (error) {
229
+ console.error(`[rn-coverage] ${(error as Error).message}`);
230
+ process.exitCode = mapErrorToExit(error);
231
+ }
232
+ })
233
+ )
234
+ .addCommand(
235
+ new Command('summary')
236
+ .description('Terminal summary via llvm-cov report')
237
+ .requiredOption('--derived-data <path>', 'Xcode derived data path')
238
+ .option('--configuration <name>', 'Xcode configuration', 'Debug')
239
+ .option('--app-name <name>', 'App product name')
240
+ .option(
241
+ '--profdata <path>',
242
+ 'Merged profdata path',
243
+ 'coverage/ios/profdata'
244
+ )
245
+ .action(async (opts, cmd) => {
246
+ const rootOpts = rootOptsFrom(cmd);
247
+ const config = applyStrictOverride(
248
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
249
+ rootOpts
250
+ );
251
+ try {
252
+ summarizeIos({
253
+ derivedData: opts.derivedData,
254
+ configuration: opts.configuration,
255
+ appName: opts.appName,
256
+ profdata: opts.profdata,
257
+ config,
258
+ });
259
+ } catch (error) {
260
+ console.error(`[rn-coverage] ${(error as Error).message}`);
261
+ process.exitCode = mapErrorToExit(error);
262
+ }
263
+ })
264
+ );
265
+
266
+ program
267
+ .command('js')
268
+ .description(
269
+ 'Istanbul/NYC TypeScript coverage (source-map remap after Metro e2e)'
270
+ )
271
+ .addCommand(
272
+ new Command('pull')
273
+ .description(
274
+ 'Pull coverage-final.json written by flush()/dumpJsCoverage'
275
+ )
276
+ .option('--platform <name>', 'android | ios', 'android')
277
+ .option('--device <id>', 'adb serial or simulator UDID')
278
+ .option('--output <dir>', 'Local output directory', 'coverage/js')
279
+ .action(async (opts, cmd) => {
280
+ const rootOpts = rootOptsFrom(cmd);
281
+ const config = applyStrictOverride(
282
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
283
+ rootOpts
284
+ );
285
+ try {
286
+ if (opts.platform === 'android') {
287
+ const deviceId = resolveAndroidDeviceId(opts.device);
288
+ const pulled = pullAndroidJsCoverage(deviceId, {
289
+ softFail: !config.strict,
290
+ outputDir: opts.output,
291
+ config,
292
+ });
293
+ if (!pulled && config.strict) {
294
+ process.exitCode = EXIT_STRICT_EMPTY;
295
+ }
296
+ } else if (opts.platform === 'ios') {
297
+ if (!opts.device) {
298
+ console.error(
299
+ '[rn-coverage] js pull --platform ios requires --device <udid>'
300
+ );
301
+ process.exitCode = EXIT_ERROR;
302
+ return;
303
+ }
304
+ const pulled = pullIosJsCoverage(opts.device, {
305
+ softFail: !config.strict,
306
+ outputDir: opts.output,
307
+ config,
308
+ });
309
+ if (!pulled && config.strict) {
310
+ process.exitCode = EXIT_STRICT_EMPTY;
311
+ }
312
+ } else {
313
+ console.error(
314
+ `[rn-coverage] js pull: --platform must be android|ios (got ${opts.platform})`
315
+ );
316
+ process.exitCode = EXIT_ERROR;
317
+ }
318
+ } catch (error) {
319
+ console.error(`[rn-coverage] ${(error as Error).message}`);
320
+ process.exitCode = mapErrorToExit(error);
321
+ }
322
+ })
323
+ )
324
+ .addCommand(
325
+ new Command('report')
326
+ .description(
327
+ 'NYC report from coverage-final.json (sourceMap remap → lcov.info)'
328
+ )
329
+ .requiredOption(
330
+ '--input <path>',
331
+ 'coverage-final.json or directory of Istanbul JSON'
332
+ )
333
+ .option('--output <dir>', 'Report directory', 'coverage/js')
334
+ .option('--cwd <path>', 'NYC cwd for include globs', process.cwd())
335
+ .option('--nyc-config <path>', 'Path to nyc.config.js')
336
+ .action(async (opts, cmd) => {
337
+ const rootOpts = rootOptsFrom(cmd);
338
+ const config = applyStrictOverride(
339
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
340
+ rootOpts
341
+ );
342
+ try {
343
+ const result = reportJsCoverage({
344
+ input: opts.input,
345
+ outputDir: opts.output,
346
+ cwd: opts.cwd,
347
+ nycConfig: opts.nycConfig,
348
+ softFail: !config.strict,
349
+ });
350
+ if (!result.ok && config.strict) {
351
+ process.exitCode = EXIT_STRICT_EMPTY;
352
+ }
353
+ } catch (error) {
354
+ console.error(`[rn-coverage] ${(error as Error).message}`);
355
+ process.exitCode = mapErrorToExit(error);
356
+ }
357
+ })
358
+ );
359
+
360
+ program
361
+ .command('assert')
362
+ .description(
363
+ 'Post-pipeline presence check (exit 2 on empty/missing expected hits)'
364
+ )
365
+ .option('--platform <name>', 'ios | android | all (default: all)', 'all')
366
+ .option('--lcov <path>', 'LCOV file to assert non-empty expected hits')
367
+ .option('--jacoco-xml <path>', 'Jacoco XML to assert non-empty LINE hits')
368
+ .action(async (opts, cmd) => {
369
+ const rootOpts = rootOptsFrom(cmd);
370
+ const config = applyStrictOverride(
371
+ await loadCoverageConfig(process.cwd(), rootOpts.config),
372
+ rootOpts
373
+ );
374
+
375
+ if (!['ios', 'android', 'all'].includes(opts.platform)) {
376
+ console.error(
377
+ `[rn-coverage] assert: --platform must be ios|android|all (got ${opts.platform})`
378
+ );
379
+ process.exitCode = EXIT_ERROR;
380
+ return;
381
+ }
382
+
383
+ const result = assertCoverage({
384
+ platform: opts.platform,
385
+ lcov: opts.lcov,
386
+ jacocoXml: opts.jacocoXml,
387
+ strict: config.strict,
388
+ config,
389
+ });
390
+
391
+ if (result.code === EXIT_OK) {
392
+ for (const line of result.message.split('\n')) {
393
+ console.log(`[rn-coverage] assert: ${line}`);
394
+ }
395
+ console.log('[rn-coverage] assert: ok');
396
+ return;
397
+ }
398
+
399
+ for (const line of result.message.split('\n')) {
400
+ if (config.strict) {
401
+ console.error(`[rn-coverage] assert: ${line}`);
402
+ } else {
403
+ console.warn(`[rn-coverage] assert: ${line}`);
404
+ }
405
+ }
406
+ if (result.code === EXIT_STRICT_EMPTY) {
407
+ console.error(
408
+ '[rn-coverage] assert: FAIL empty or missing native coverage (exit 2)'
409
+ );
410
+ }
411
+ process.exitCode = result.code;
412
+ });
413
+
414
+ await program.parseAsync(process.argv);
415
+ }
416
+
417
+ main().catch((error: unknown) => {
418
+ console.error(`[rn-coverage] ${(error as Error).message}`);
419
+ process.exitCode = EXIT_ERROR;
420
+ });