@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 +21 -0
- package/README.md +63 -0
- package/bin/kensho-xcuitest.js +58 -0
- package/package.json +37 -0
- package/src/parser.js +360 -0
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
|
+
}
|