@kaizenreport/kensho-xcuitest 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Ordoñez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @kaizenreport/kensho-xcuitest
2
+
3
+ Kensho adapter for native iOS XCUITest — converts an `.xcresult` bundle from `xcodebuild test` into `kensho-results/` so the Kensho CLI can build a static report.
4
+
5
+ ## Platform requirement
6
+
7
+ `.xcresult` bundles are an Apple-proprietary format. Parsing them requires **macOS + Xcode Command Line Tools** (the `xcrun xcresulttool` binary).
8
+
9
+ For non-mac CI runners, generate the JSON dump on the mac side once and ship it through:
10
+
11
+ ```bash
12
+ # on macOS:
13
+ xcrun xcresulttool get --format json --path ./out.xcresult > out.xcresult.json
14
+ # anywhere:
15
+ npx kensho-xcuitest --input out.xcresult.json --output kensho-results
16
+ ```
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pnpm add -D @kaizenreport/kensho-xcuitest @kaizenreport/kensho
22
+ ```
23
+
24
+ ## Use
25
+
26
+ ```bash
27
+ xcodebuild -scheme AcmeUITests \
28
+ -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.4' \
29
+ test -resultBundlePath ./out.xcresult
30
+
31
+ npx kensho-xcuitest --input ./out.xcresult --output ./kensho-results
32
+ npx kensho validate ./kensho-results
33
+ npx kensho generate --input ./kensho-results --output ./kensho-report
34
+ npx kensho open --report ./kensho-report
35
+ ```
36
+
37
+ ## What gets captured
38
+
39
+ | `xcresulttool` field | Kensho field |
40
+ | -------------------------------------------------------- | ----------------------------------- |
41
+ | `ActionTestSummary.name` | `case.name` |
42
+ | `ActionTestSummary.identifier` + parent group names | `case.fullName` / `case.suite[]` |
43
+ | `documentLocationInCreatingWorkspace.url` | `case.filePath` + `case.line` |
44
+ | `testStatus` (`Success`/`Failure`/`ExpectedFailure`/`Skipped`) | `case.status` (`pass`/`fail`/`broken`/`skip`) |
45
+ | `duration` (seconds) | `case.duration` (ms) |
46
+ | `activitySummaries[]` (recursive) | `case.steps[]` (with sub-steps) |
47
+ | `activitySummaries[].attachments[]` | `case.steps[].attachments[]` |
48
+ | `failureSummaries[]` | `case.errors[]` |
49
+ | `runDestination.targetDeviceRecord.modelName` | `run.env.device` |
50
+ | `runDestination.targetDeviceRecord.operatingSystemVersion` | `run.env.osVersion` |
51
+ | `testableSummaries[].targetName` | `case.labels.target` |
52
+
53
+ `framework.name = 'xcuitest'`. Status mapping:
54
+ - `Success` → `pass`
55
+ - `Failure` → `fail`
56
+ - `ExpectedFailure` → `broken`
57
+ - `Skipped` → `skip`
58
+
59
+ ## Notes on attachments
60
+
61
+ Real `.xcresult` bundles store screenshots/videos inside the bundle as referenced blobs. The adapter calls `xcrun xcresulttool export --type file --id <ref>` to pull each one out into `kensho-results/attachments/<caseId>/`.
62
+
63
+ When running on a fixture JSON dump (no real bundle next to it), the adapter writes a small placeholder file so the case still references something — the schema requires `relativePath` to point at a file on disk.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // kensho-xcuitest — convert an .xcresult bundle (or its xcresulttool JSON dump)
3
+ // into kensho-results/.
4
+ //
5
+ // kensho-xcuitest --input ./build/Test.xcresult --output ./kensho-results
6
+ // kensho-xcuitest --input ./fixtures/sample.xcresult.json --output ./kensho-results
7
+ //
8
+ // Note: real .xcresult bundles require macOS + Xcode CLT (xcrun xcresulttool).
9
+
10
+ import { convert } from '../src/parser.js';
11
+
12
+ function parseArgs(argv) {
13
+ const out = {};
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i];
16
+ if (a === '--input' || a === '-i') out.input = argv[++i];
17
+ else if (a === '--output' || a === '-o') out.output = argv[++i];
18
+ else if (a === '--project-name') out.projectName = argv[++i];
19
+ else if (a === '--project-slug') out.projectSlug = argv[++i];
20
+ else if (a === '--run-id') out.runId = argv[++i];
21
+ else if (a === '--help' || a === '-h') out.help = true;
22
+ }
23
+ return out;
24
+ }
25
+
26
+ const args = parseArgs(process.argv.slice(2));
27
+
28
+ if (args.help || !args.input) {
29
+ console.log(`kensho-xcuitest
30
+
31
+ Usage:
32
+ kensho-xcuitest --input <path> [--output kensho-results]
33
+ [--project-name "Acme iOS"] [--project-slug acme-ios]
34
+ [--run-id run_…]
35
+
36
+ Inputs:
37
+ <path> Either an .xcresult bundle or a JSON dump from xcresulttool.
38
+
39
+ Examples:
40
+ xcodebuild -scheme AcmeUITests test -resultBundlePath ./out.xcresult
41
+ kensho-xcuitest --input ./out.xcresult --output ./kensho-results
42
+ kensho generate --input ./kensho-results --output ./kensho-report
43
+ `);
44
+ process.exit(args.input ? 0 : 1);
45
+ }
46
+
47
+ try {
48
+ convert({
49
+ input: args.input,
50
+ output: args.output || 'kensho-results',
51
+ project: { name: args.projectName, slug: args.projectSlug },
52
+ runId: args.runId,
53
+ });
54
+ } catch (e) {
55
+ console.error('[kensho-xcuitest]', e.message || e);
56
+ if (process.env.KENSHO_DEBUG) console.error(e.stack);
57
+ process.exit(1);
58
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kaizenreport/kensho-xcuitest",
3
+ "version": "0.1.0",
4
+ "description": "Kensho adapter for XCUITest — converts an .xcresult bundle from `xcodebuild test` into kensho-results/ via xcrun xcresulttool.",
5
+ "type": "module",
6
+ "main": "src/parser.js",
7
+ "exports": {
8
+ ".": "./src/parser.js"
9
+ },
10
+ "bin": {
11
+ "kensho-xcuitest": "bin/kensho-xcuitest.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "@kaizenreport/kensho-schema": "0.1.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "license": "Apache-2.0",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/brandon1794/kensho.git",
31
+ "directory": "packages/xcuitest"
32
+ },
33
+ "homepage": "https://github.com/brandon1794/kensho/tree/main/packages/xcuitest#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/brandon1794/kensho/issues"
36
+ }
37
+ }
package/src/parser.js ADDED
@@ -0,0 +1,360 @@
1
+ // @kaizenreport/kensho-xcuitest — convert an .xcresult bundle into kensho-results/.
2
+ //
3
+ // xcresulttool emits a deeply-nested JSON tree. We walk:
4
+ // actions[] → actionResult.testsRef → ActionTestPlanRunSummaries
5
+ // summaries[].testableSummaries[].tests[] (recursive ActionTestSummaryGroup)
6
+ // → leaves are ActionTestSummary objects with status/duration/activitySummaries[]
7
+ // activitySummaries[] are recursive too — each becomes a Kensho step.
8
+ //
9
+ // We support two modes:
10
+ // 1) `--input some.xcresult` — calls `xcrun xcresulttool` to extract JSON. macOS only.
11
+ // 2) `--input some.json` — reads a pre-extracted JSON tree (used in the demo
12
+ // fixture and in CI on non-mac machines).
13
+
14
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, copyFileSync } from 'node:fs';
15
+ import { resolve, basename, extname, relative } from 'node:path';
16
+ import { execFileSync, spawnSync } from 'node:child_process';
17
+ import { emptyRun, computeTotals, stableCaseId, validateRun } from '@kaizenreport/kensho-schema';
18
+
19
+ function shortId(prefix) { return prefix + '-' + Math.random().toString(36).slice(2, 10); }
20
+
21
+ function envInfo() {
22
+ const isCI = !!process.env.CI;
23
+ return {
24
+ ci: isCI && process.env.GITHUB_ACTIONS ? 'github-actions'
25
+ : isCI && process.env.CIRCLECI ? 'circleci'
26
+ : isCI && process.env.GITLAB_CI ? 'gitlab'
27
+ : isCI && process.env.JENKINS_URL ? 'jenkins'
28
+ : isCI ? 'unknown' : 'local',
29
+ branch: process.env.GITHUB_REF_NAME || process.env.CIRCLE_BRANCH,
30
+ commit: process.env.GITHUB_SHA || process.env.CIRCLE_SHA1,
31
+ os: 'darwin',
32
+ nodeVersion: process.version,
33
+ appVersion: process.env.APP_VERSION,
34
+ };
35
+ }
36
+
37
+ // xcresulttool wraps every value in { _type: { _name }, _value }, or for arrays,
38
+ // { _values: [...] }. These helpers strip that ceremony so we can walk a normal tree.
39
+ export function unwrap(node) {
40
+ if (node == null) return node;
41
+ if (typeof node !== 'object') return node;
42
+ if (Array.isArray(node)) return node.map(unwrap);
43
+ if ('_value' in node && Object.keys(node).length <= 2) return unwrap(node._value);
44
+ if ('_values' in node && Array.isArray(node._values)) return node._values.map(unwrap);
45
+ const out = {};
46
+ for (const [k, v] of Object.entries(node)) {
47
+ if (k === '_type') continue;
48
+ out[k] = unwrap(v);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const STATUS_MAP = {
54
+ Success: 'pass',
55
+ Failure: 'fail',
56
+ Expected: 'pass',
57
+ ExpectedFailure: 'broken',
58
+ Skipped: 'skip',
59
+ };
60
+
61
+ function mapStatus(s) {
62
+ if (!s) return 'broken';
63
+ const k = String(s);
64
+ return STATUS_MAP[k] || (/^(skip)/i.test(k) ? 'skip' : /^(fail)/i.test(k) ? 'fail' : /^(pass|success)/i.test(k) ? 'pass' : 'broken');
65
+ }
66
+
67
+ /**
68
+ * Run `xcrun xcresulttool get --format json` to extract a JSON tree from an .xcresult
69
+ * bundle. macOS + Xcode CLT required. Returns the parsed object.
70
+ */
71
+ export function readXcresult(bundlePath, { id } = {}) {
72
+ const args = ['xcresulttool', 'get', '--format', 'json', '--path', bundlePath];
73
+ if (id) args.push('--id', id);
74
+ const buf = execFileSync('xcrun', args, { maxBuffer: 256 * 1024 * 1024 });
75
+ return JSON.parse(buf.toString('utf8'));
76
+ }
77
+
78
+ /**
79
+ * Try to extract a per-test detailed summary from an .xcresult bundle. Falls
80
+ * back to whatever's already inlined in the top-level summary if xcresulttool
81
+ * isn't reachable (non-mac CI runner, etc.).
82
+ */
83
+ function tryReadTestSummary(bundlePath, summaryRefId) {
84
+ if (!bundlePath || !summaryRefId) return null;
85
+ const r = spawnSync('xcrun', ['xcresulttool', 'get', '--format', 'json', '--path', bundlePath, '--id', summaryRefId], { maxBuffer: 64 * 1024 * 1024 });
86
+ if (r.status !== 0) return null;
87
+ try { return unwrap(JSON.parse(r.stdout.toString('utf8'))); } catch { return null; }
88
+ }
89
+
90
+ function activityToStep(act, baseEpochMs, attachmentsCb) {
91
+ const startMs = act.start != null ? Date.parse(act.start) : (baseEpochMs || Date.now());
92
+ const duration = Math.max(0, Math.round((act.duration || 0) * 1000)) || 0;
93
+ const step = {
94
+ id: shortId('step'),
95
+ title: act.title || act.activityType || 'activity',
96
+ action: act.activityType,
97
+ status: act.failureSummaryIDs && act.failureSummaryIDs.length ? 'fail' : 'pass',
98
+ startedAt: new Date(startMs).toISOString(),
99
+ duration,
100
+ };
101
+ const atts = [];
102
+ for (const a of act.attachments || []) {
103
+ if (typeof attachmentsCb === 'function') {
104
+ const att = attachmentsCb(a);
105
+ if (att) atts.push(att);
106
+ }
107
+ }
108
+ if (atts.length) step.attachments = atts;
109
+ if (Array.isArray(act.subactivities) && act.subactivities.length) {
110
+ step.children = act.subactivities.map(s => activityToStep(s, startMs, attachmentsCb));
111
+ }
112
+ return step;
113
+ }
114
+
115
+ function walkTests(node, suiteChain, leaves) {
116
+ if (!node) return;
117
+ // ActionTestSummaryGroup: has subtests; ActionTestSummary: has identifier+testStatus
118
+ if (node.subtests) {
119
+ const next = node.name ? [...suiteChain, node.name] : suiteChain;
120
+ for (const t of node.subtests) walkTests(t, next, leaves);
121
+ return;
122
+ }
123
+ if (Array.isArray(node)) {
124
+ for (const t of node) walkTests(t, suiteChain, leaves);
125
+ return;
126
+ }
127
+ if (node.identifier || node.identifierURL || node.testStatus) {
128
+ leaves.push({ test: node, suite: suiteChain });
129
+ }
130
+ }
131
+
132
+ function copyAttachmentToBundle({ a, attachmentsDir, caseId, bundlePath, outputDir }) {
133
+ const filename = a.filename || a.name || 'attachment';
134
+ const ext = extname(filename).toLowerCase();
135
+ const kind = ext === '.png' || ext === '.jpg' || ext === '.jpeg' ? 'screenshot'
136
+ : ext === '.mp4' || ext === '.mov' ? 'video'
137
+ : ext === '.txt' || ext === '.log' ? 'log'
138
+ : ext === '.json' ? 'json' : 'text';
139
+ const mime = ext === '.png' ? 'image/png'
140
+ : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
141
+ : ext === '.mp4' ? 'video/mp4' : ext === '.mov' ? 'video/quicktime'
142
+ : ext === '.json' ? 'application/json' : 'text/plain';
143
+ const refId = a.payloadRef && (a.payloadRef.id || a.payloadRef);
144
+ const attId = shortId('att');
145
+ const destDir = resolve(attachmentsDir, caseId);
146
+ mkdirSync(destDir, { recursive: true });
147
+ const destPath = resolve(destDir, attId + '_' + filename);
148
+ let sz = 0;
149
+
150
+ if (refId && bundlePath) {
151
+ const r = spawnSync('xcrun', ['xcresulttool', 'export', '--type', 'file', '--path', bundlePath, '--id', refId, '--output-path', destPath]);
152
+ if (r.status === 0 && existsSync(destPath)) sz = statSync(destPath).size;
153
+ }
154
+ if (!sz && a.localPath && existsSync(a.localPath)) {
155
+ try { copyFileSync(a.localPath, destPath); sz = statSync(destPath).size; } catch {}
156
+ }
157
+ if (!sz) {
158
+ // Synthesise a placeholder so the case still references something; fixtures
159
+ // exercise this path because they don't ship the binary blob.
160
+ writeFileSync(destPath, `[xcresult attachment placeholder for ${filename}]`);
161
+ sz = statSync(destPath).size;
162
+ }
163
+ return {
164
+ id: attId,
165
+ kind,
166
+ relativePath: relative(outputDir, destPath),
167
+ mimeType: mime,
168
+ sizeBytes: sz,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Convert an xcresulttool JSON tree (already loaded) into kensho-results/.
174
+ */
175
+ export function convertParsed(parsed, { outputDir, project, runId, bundlePath } = {}) {
176
+ const outDir = resolve(process.cwd(), outputDir || 'kensho-results');
177
+ const casesDir = resolve(outDir, 'cases');
178
+ const attachmentsDir = resolve(outDir, 'attachments');
179
+ mkdirSync(outDir, { recursive: true });
180
+ mkdirSync(casesDir, { recursive: true });
181
+ mkdirSync(attachmentsDir, { recursive: true });
182
+
183
+ const root = unwrap(parsed);
184
+ const startedAt = root.metadataRef?.creatingWorkspaceFilePath || new Date().toISOString();
185
+ const runStarted = root.startedTime || new Date().toISOString();
186
+ const runFinished = root.endedTime || new Date().toISOString();
187
+
188
+ const actions = root.actions || [];
189
+ const testRefs = [];
190
+ for (const a of actions) {
191
+ const ref = a.actionResult?.testsRef?.id;
192
+ if (ref) testRefs.push(ref);
193
+ if (a.testPlanRunSummaries) testRefs.push(a.testPlanRunSummaries);
194
+ }
195
+
196
+ // The fixture path inlines `testPlanRunSummaries` directly. Real bundles
197
+ // reference it by id and we have to fetch via xcresulttool.
198
+ const summaryTrees = [];
199
+ for (const a of actions) {
200
+ if (a.testPlanRunSummaries) summaryTrees.push(a.testPlanRunSummaries);
201
+ else if (a.actionResult?.testsRef?.id && bundlePath) {
202
+ const sub = tryReadTestSummary(bundlePath, a.actionResult.testsRef.id);
203
+ if (sub) summaryTrees.push(sub);
204
+ }
205
+ }
206
+
207
+ const usedIds = new Set();
208
+ const cases = [];
209
+
210
+ for (const tree of summaryTrees) {
211
+ const summaries = tree.summaries || [];
212
+ for (const s of summaries) {
213
+ const testables = s.testableSummaries || [];
214
+ for (const ts of testables) {
215
+ const targetName = ts.name || ts.targetName;
216
+ const targetSuite = targetName ? [targetName] : [];
217
+ const leaves = [];
218
+ for (const t of ts.tests || []) walkTests(t, targetSuite, leaves);
219
+ for (const { test, suite } of leaves) {
220
+ const c = caseFromTest(test, suite, ts, { usedIds, bundlePath, outputDir: outDir, casesDir, attachmentsDir });
221
+ writeFileSync(resolve(casesDir, c.id + '.json'), JSON.stringify(c, null, 2));
222
+ cases.push(c);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ const env = envInfo();
229
+ // Promote device caps from the destination metadata if present.
230
+ for (const a of actions) {
231
+ const dest = a.runDestination;
232
+ if (dest) {
233
+ if (dest.targetDeviceRecord?.modelName) env.device = String(dest.targetDeviceRecord.modelName);
234
+ if (dest.targetDeviceRecord?.operatingSystemVersion) env.osVersion = String(dest.targetDeviceRecord.operatingSystemVersion);
235
+ if (dest.targetSDKRecord?.name) env.platform = String(dest.targetSDKRecord.name);
236
+ }
237
+ }
238
+
239
+ const run = emptyRun({
240
+ id: runId || ('run_' + new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)),
241
+ project: {
242
+ name: project?.name || 'Unknown project',
243
+ slug: project?.slug || 'unknown',
244
+ url: project?.url,
245
+ },
246
+ framework: { name: 'xcuitest', version: root.creatingWorkspaceFilePath ? 'xcode' : 'xcresulttool' },
247
+ env,
248
+ startedAt: typeof runStarted === 'string' ? runStarted : new Date(runStarted).toISOString(),
249
+ });
250
+ run.finishedAt = typeof runFinished === 'string' ? runFinished : new Date(runFinished).toISOString();
251
+ run.durationMs = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
252
+ run.testCases = cases;
253
+ run.totals = computeTotals(cases);
254
+
255
+ writeFileSync(resolve(outDir, 'run.json'), JSON.stringify(run, null, 2));
256
+ const { ok, errors } = validateRun(run);
257
+ if (!ok) {
258
+ console.warn('[kensho] run.json failed validation:');
259
+ for (const e of errors.slice(0, 8)) console.warn(' -', e);
260
+ }
261
+ console.log(`[kensho] wrote ${cases.length} cases + run.json to ${outDir}`);
262
+ return { outputDir: outDir, cases: cases.length, ok };
263
+ }
264
+
265
+ function caseFromTest(test, suite, testable, ctx) {
266
+ const name = test.name || test.identifier || 'unnamed';
267
+ const fullName = [...suite, name].join(' › ');
268
+ const filePath = test.documentLocationInCreatingWorkspace?.url
269
+ ? String(test.documentLocationInCreatingWorkspace.url).replace(/^file:\/\//, '').split('#')[0]
270
+ : (testable?.targetName ? testable.targetName : undefined);
271
+ const line = parseLineFromUrl(test.documentLocationInCreatingWorkspace?.url);
272
+
273
+ let id = stableCaseId(fullName, filePath);
274
+ if (ctx.usedIds.has(id)) {
275
+ let i = 2;
276
+ while (ctx.usedIds.has(id + '_' + i)) i++;
277
+ id = id + '_' + i;
278
+ }
279
+ ctx.usedIds.add(id);
280
+
281
+ const status = mapStatus(test.testStatus);
282
+ const duration = Math.max(0, Math.round((test.duration || 0) * 1000));
283
+ const startMs = Date.now() - duration;
284
+ const startedAt = new Date(startMs).toISOString();
285
+ const finishedAt = new Date(startMs + duration).toISOString();
286
+
287
+ const attachmentsCb = (a) => copyAttachmentToBundle({
288
+ a,
289
+ attachmentsDir: ctx.attachmentsDir,
290
+ caseId: id,
291
+ bundlePath: ctx.bundlePath,
292
+ outputDir: ctx.outputDir,
293
+ });
294
+
295
+ const steps = (test.activitySummaries || []).map(act => activityToStep(act, startMs, attachmentsCb));
296
+
297
+ // Top-level attachments (some xcresult bundles surface them outside activities).
298
+ const attachments = [];
299
+ for (const a of test.attachments || []) {
300
+ const att = attachmentsCb(a);
301
+ if (att) attachments.push(att);
302
+ }
303
+
304
+ const errors = (test.failureSummaries || []).map(f => ({
305
+ message: f.message || f.issueType || 'failure',
306
+ type: f.issueType,
307
+ stack: [f.fileName, f.lineNumber].filter(Boolean).join(':'),
308
+ }));
309
+
310
+ const labels = {};
311
+ if (testable?.targetName) labels.target = String(testable.targetName);
312
+ if (testable?.identifierURL) labels.testTarget = String(testable.identifierURL);
313
+
314
+ return {
315
+ id,
316
+ name,
317
+ fullName,
318
+ filePath,
319
+ line,
320
+ suite,
321
+ tags: [],
322
+ labels: Object.keys(labels).length ? labels : undefined,
323
+ status,
324
+ startedAt,
325
+ finishedAt,
326
+ duration,
327
+ retries: 0,
328
+ platform: 'iOS',
329
+ steps,
330
+ errors: errors.length ? errors : undefined,
331
+ attachments,
332
+ logs: [],
333
+ };
334
+ }
335
+
336
+ function parseLineFromUrl(url) {
337
+ if (!url) return undefined;
338
+ const m = /#.*?StartingLineNumber=(\d+)/.exec(url);
339
+ return m ? parseInt(m[1], 10) : undefined;
340
+ }
341
+
342
+ /**
343
+ * High-level entrypoint used by the CLI.
344
+ *
345
+ * @param {{ input: string, output?: string, project?: object, runId?: string }} opts
346
+ */
347
+ export function convert({ input, output, project, runId }) {
348
+ const inputPath = resolve(process.cwd(), input);
349
+ if (!existsSync(inputPath)) throw new Error(`input not found: ${inputPath}`);
350
+
351
+ let parsed;
352
+ let bundlePath;
353
+ if (statSync(inputPath).isDirectory()) {
354
+ bundlePath = inputPath;
355
+ parsed = readXcresult(inputPath);
356
+ } else {
357
+ parsed = JSON.parse(readFileSync(inputPath, 'utf8'));
358
+ }
359
+ return convertParsed(parsed, { outputDir: output, project, runId, bundlePath });
360
+ }