Skip to content

Commit 4f92d79

Browse files
committed
fix(platform-ios): persist xctest startup logs
1 parent a38828e commit 4f92d79

6 files changed

Lines changed: 232 additions & 22 deletions

File tree

.github/workflows/e2e-tests.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ jobs:
104104
echo "HARNESS_RUNNER=$HARNESS_RUNNER"
105105
echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE"
106106
107+
- name: Upload Harness logs
108+
if: always()
109+
uses: actions/upload-artifact@v4
110+
with:
111+
name: harness-logs-e2e-android
112+
path: apps/playground/.harness/logs
113+
if-no-files-found: ignore
114+
107115
e2e-ios:
108116
name: E2E iOS
109117
runs-on: macos-latest
@@ -197,6 +205,14 @@ jobs:
197205
echo "HARNESS_RUNNER=$HARNESS_RUNNER"
198206
echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE"
199207
208+
- name: Upload Harness logs
209+
if: always()
210+
uses: actions/upload-artifact@v4
211+
with:
212+
name: harness-logs-e2e-ios
213+
path: apps/playground/.harness/logs
214+
if-no-files-found: ignore
215+
200216
e2e-web:
201217
name: E2E Web
202218
runs-on: ubuntu-22.04
@@ -243,6 +259,14 @@ jobs:
243259
echo "HARNESS_RUNNER=$HARNESS_RUNNER"
244260
echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE"
245261
262+
- name: Upload Harness logs
263+
if: always()
264+
uses: actions/upload-artifact@v4
265+
with:
266+
name: harness-logs-e2e-web
267+
path: apps/playground/.harness/logs
268+
if-no-files-found: ignore
269+
246270
crash-validate-android:
247271
name: Crash Validation Android
248272
runs-on: ubuntu-22.04
@@ -349,6 +373,14 @@ jobs:
349373
exit 1
350374
fi
351375
376+
- name: Upload Harness logs
377+
if: always()
378+
uses: actions/upload-artifact@v4
379+
with:
380+
name: harness-logs-crash-validate-android
381+
path: apps/playground/.harness/logs
382+
if-no-files-found: ignore
383+
352384
crash-validate-ios:
353385
name: Crash Validation iOS
354386
runs-on: macos-latest
@@ -465,3 +497,11 @@ jobs:
465497
echo "ERROR: No crash report artifacts found in $CRASH_DIR"
466498
exit 1
467499
fi
500+
501+
- name: Upload Harness logs
502+
if: always()
503+
uses: actions/upload-artifact@v4
504+
with:
505+
name: harness-logs-crash-validate-ios
506+
path: apps/playground/.harness/logs
507+
if-no-files-found: ignore

packages/platform-ios/src/__tests__/xctest-agent.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
55
import { createHash } from 'node:crypto';
6+
import { PassThrough } from 'node:stream';
67
import { fileURLToPath } from 'node:url';
78

89
const mocks = vi.hoisted(() => ({
@@ -101,6 +102,8 @@ const createLongRunningSubprocess = (options?: {
101102
return childProcess;
102103
}),
103104
signalCode: null,
105+
stderr: new PassThrough(),
106+
stdout: new PassThrough(),
104107
};
105108

106109
const iterable = {
@@ -184,6 +187,9 @@ describe('xctest-agent orchestration', () => {
184187
path.join(buildRoot, 'device', 'build-manifest.json'),
185188
JSON.stringify({
186189
buildInputsHash: getCurrentInputsHash(),
190+
codeSign: {
191+
teamId: 'TESTTEAM01',
192+
},
187193
destinationKind: 'device',
188194
}),
189195
);
@@ -237,8 +243,8 @@ describe('xctest-agent orchestration', () => {
237243
]),
238244
expect.objectContaining({
239245
env: expect.objectContaining({
240-
HARNESS_XCTEST_AGENT_MODE: 'test',
241-
HARNESS_XCTEST_AGENT_PORT: '49152',
246+
TEST_RUNNER_HARNESS_XCTEST_AGENT_MODE: 'test',
247+
TEST_RUNNER_HARNESS_XCTEST_AGENT_PORT: '49152',
242248
}),
243249
}),
244250
);
@@ -249,6 +255,19 @@ describe('xctest-agent orchestration', () => {
249255
expect(mocks.configurePermissions).toHaveBeenCalledWith({
250256
autoAcceptPermissions: true,
251257
});
258+
const logDirectories = fs.readdirSync(path.join(tempProjectRoot, '.harness', 'logs'));
259+
expect(logDirectories).toHaveLength(1);
260+
const xcodebuildLogPath = path.join(
261+
tempProjectRoot,
262+
'.harness',
263+
'logs',
264+
logDirectories[0]!,
265+
'xcodebuild.log'
266+
);
267+
expect(fs.existsSync(xcodebuildLogPath)).toBe(true);
268+
expect(fs.readFileSync(xcodebuildLogPath, 'utf8')).toContain(
269+
'command=xcodebuild test-without-building'
270+
);
252271

253272
await controller.dispose();
254273

packages/platform-ios/src/xctest-agent.ts

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import {
2+
createHarnessArtifactDirectory,
23
getAvailablePort,
34
logger,
45
spawn,
56
type Subprocess,
67
} from '@react-native-harness/tools';
78
import fs from 'node:fs';
89
import { createHash } from 'node:crypto';
10+
import { PassThrough, pipeline } from 'node:stream';
11+
import { promisify } from 'node:util';
912
import path from 'node:path';
1013
import { fileURLToPath } from 'node:url';
1114
import {
@@ -24,11 +27,12 @@ const XCTEST_AGENT_SCHEME_NAME = 'HarnessXCTestAgent';
2427
const XCTEST_AGENT_PORT_ENV = 'HARNESS_XCTEST_AGENT_PORT';
2528
const XCTEST_AGENT_TARGET_BUNDLE_ID_ENV =
2629
'HARNESS_XCTEST_AGENT_TARGET_BUNDLE_ID';
27-
const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 30_000;
30+
const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 60_000;
2831
const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 5_000;
2932
const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250;
3033
const HARNESS_DIRNAME = '.harness';
3134
const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent';
35+
const pipelineAsync = promisify(pipeline);
3236

3337
type XCTestAgentTarget =
3438
| {
@@ -376,6 +380,53 @@ const getErrorMessage = (error: unknown): string => {
376380
return error instanceof Error ? error.message : String(error);
377381
};
378382

383+
const attachProcessOutputLog = async (options: {
384+
command: string;
385+
logFilePath: string;
386+
process: Subprocess;
387+
}) => {
388+
fs.mkdirSync(path.dirname(options.logFilePath), { recursive: true });
389+
fs.writeFileSync(
390+
options.logFilePath,
391+
[
392+
`timestamp=${new Date().toISOString()}`,
393+
`command=${options.command}`,
394+
'',
395+
].join('\n'),
396+
'utf8'
397+
);
398+
const output = fs.createWriteStream(options.logFilePath, { flags: 'a' });
399+
400+
const childProcess = await options.process.nodeChildProcess;
401+
const mergedOutput = new PassThrough();
402+
const forwardStream = async (
403+
stream: NodeJS.ReadableStream | null | undefined,
404+
label: 'stdout' | 'stderr'
405+
) => {
406+
if (!stream) {
407+
return;
408+
}
409+
410+
for await (const chunk of stream) {
411+
mergedOutput.write(`[${label}] `);
412+
mergedOutput.write(chunk);
413+
if (Buffer.isBuffer(chunk) ? !chunk.includes(0x0a) : !String(chunk).endsWith('\n')) {
414+
mergedOutput.write('\n');
415+
}
416+
}
417+
};
418+
419+
const pipeTask = pipelineAsync(mergedOutput, output);
420+
const forwardTask = Promise.all([
421+
forwardStream(childProcess.stdout, 'stdout'),
422+
forwardStream(childProcess.stderr, 'stderr'),
423+
]).finally(() => {
424+
mergedOutput.end();
425+
});
426+
427+
void Promise.allSettled([pipeTask, forwardTask]);
428+
};
429+
379430
export const createXCTestAgentController = (options: {
380431
appBundleId?: string;
381432
target: XCTestAgentTarget;
@@ -390,6 +441,13 @@ export const createXCTestAgentController = (options: {
390441
options.startupTimeoutMs ?? XCTEST_AGENT_STARTUP_TIMEOUT_MS;
391442
const shutdownTimeoutMs =
392443
options.shutdownTimeoutMs ?? XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS;
444+
const logArtifacts = createHarnessArtifactDirectory({
445+
artifactType: 'logs',
446+
bundleId: options.appBundleId,
447+
platformId: 'ios',
448+
runnerName: `xctest-agent-${target.kind}`,
449+
});
450+
const xcodebuildLogPath = path.join(logArtifacts.directoryPath, 'xcodebuild.log');
393451
let prepared = false;
394452
let agentProcess: Subprocess | null = null;
395453
let agentClient: ReturnType<typeof createXCTestAgentClient> | null = null;
@@ -493,23 +551,24 @@ export const createXCTestAgentController = (options: {
493551
target.kind
494552
);
495553
xctestAgentLogger.debug('Using XCTest agent port %d', port);
554+
const xcodebuildArgs = [
555+
'test-without-building',
556+
'-project',
557+
getXCTestAgentProjectFilePath(),
558+
'-scheme',
559+
XCTEST_AGENT_SCHEME_NAME,
560+
'-destination',
561+
getXCTestAgentRunDestination(target),
562+
'-parallel-testing-enabled',
563+
'NO',
564+
'-maximum-parallel-testing-workers',
565+
'1',
566+
'-derivedDataPath',
567+
getXCTestAgentDerivedDataPath(target),
568+
];
496569
agentProcess = spawn(
497570
'xcodebuild',
498-
[
499-
'test-without-building',
500-
'-project',
501-
getXCTestAgentProjectFilePath(),
502-
'-scheme',
503-
XCTEST_AGENT_SCHEME_NAME,
504-
'-destination',
505-
getXCTestAgentRunDestination(target),
506-
'-parallel-testing-enabled',
507-
'NO',
508-
'-maximum-parallel-testing-workers',
509-
'1',
510-
'-derivedDataPath',
511-
getXCTestAgentDerivedDataPath(target),
512-
],
571+
xcodebuildArgs,
513572
{
514573
cwd: getXCTestAgentProjectRoot(),
515574
env: {
@@ -519,10 +578,14 @@ export const createXCTestAgentController = (options: {
519578
...getLaunchEnvironment(),
520579
}),
521580
},
522-
stdout: 'ignore',
523-
stderr: 'ignore',
524581
}
525582
);
583+
void attachProcessOutputLog({
584+
command: ['xcodebuild', ...xcodebuildArgs].join(' '),
585+
logFilePath: xcodebuildLogPath,
586+
process: agentProcess,
587+
});
588+
xctestAgentLogger.info('Saving XCTest agent xcodebuild logs to %s', xcodebuildLogPath);
526589

527590
const currentProcess = agentProcess;
528591
if (typeof currentProcess.catch === 'function') {
@@ -550,9 +613,10 @@ export const createXCTestAgentController = (options: {
550613
await client.configurePermissions(runtimeConfiguration.permissions);
551614
} catch (error) {
552615
xctestAgentLogger.warn(
553-
'XCTest agent startup failed for %s: %s',
616+
'XCTest agent startup failed for %s: %s (logs: %s)',
554617
target.kind,
555-
getErrorMessage(error)
618+
getErrorMessage(error),
619+
xcodebuildLogPath
556620
);
557621
await transport.dispose();
558622
agentClient = null;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { createHarnessArtifactDirectory } from '../harness-artifacts.js';
6+
7+
describe('createHarnessArtifactDirectory', () => {
8+
const rootDir = fs.mkdtempSync(
9+
path.join(tmpdir(), 'rn-harness-artifact-directories-')
10+
);
11+
12+
afterEach(() => {
13+
fs.rmSync(rootDir, { recursive: true, force: true });
14+
fs.mkdirSync(rootDir, { recursive: true });
15+
});
16+
17+
it('creates a reusable run directory inside the requested artifact type', () => {
18+
const artifacts = createHarnessArtifactDirectory({
19+
artifactType: 'logs',
20+
bundleId: 'com.harnessplayground.dev',
21+
platformId: 'ios',
22+
rootDir,
23+
runTimestamp: '2026-04-29T10-45-31-645Z',
24+
runnerName: 'xctest-agent simulator',
25+
});
26+
27+
expect(artifacts.rootDir).toBe(path.join(rootDir, 'logs'));
28+
expect(artifacts.directoryPath).toBe(
29+
path.join(
30+
rootDir,
31+
'logs',
32+
'2026-04-29T10-45-31-645Z--ios--xctest-agent-simulator--com.harnessplayground.dev'
33+
)
34+
);
35+
expect(fs.existsSync(artifacts.directoryPath)).toBe(true);
36+
});
37+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
const getDefaultHarnessRoot = () => path.join(process.cwd(), '.harness');
5+
6+
const sanitizePathSegment = (value: string) =>
7+
value
8+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
9+
.replace(/-+/g, '-')
10+
.replace(/^-|-$/g, '') || 'artifact';
11+
12+
const formatRunTimestamp = (value: Date) =>
13+
value.toISOString().replace(/[:.]/g, '-');
14+
15+
export const createHarnessArtifactDirectory = ({
16+
artifactType,
17+
bundleId,
18+
platformId,
19+
rootDir = getDefaultHarnessRoot(),
20+
runTimestamp = formatRunTimestamp(new Date()),
21+
runnerName,
22+
}: {
23+
artifactType: string;
24+
bundleId?: string;
25+
platformId: string;
26+
rootDir?: string;
27+
runTimestamp?: string;
28+
runnerName: string;
29+
}) => {
30+
const artifactRoot = path.join(rootDir, sanitizePathSegment(artifactType));
31+
const runDirName = [
32+
runTimestamp,
33+
platformId,
34+
runnerName,
35+
bundleId,
36+
]
37+
.filter(Boolean)
38+
.map((value) => sanitizePathSegment(value))
39+
.join('--');
40+
const directoryPath = path.join(artifactRoot, runDirName);
41+
42+
fs.mkdirSync(directoryPath, { recursive: true });
43+
44+
return {
45+
directoryPath,
46+
rootDir: artifactRoot,
47+
runTimestamp,
48+
};
49+
};

packages/tools/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export * from './error.js';
99
export * from './events.js';
1010
export * from './packages.js';
1111
export * from './crash-artifacts.js';
12+
export * from './harness-artifacts.js';
1213
export * from './regex.js';
1314
export * from './isInteractive.js';

0 commit comments

Comments
 (0)