11import {
2+ createHarnessArtifactDirectory ,
23 getAvailablePort ,
34 logger ,
45 spawn ,
56 type Subprocess ,
67} from '@react-native-harness/tools' ;
78import fs from 'node:fs' ;
89import { createHash } from 'node:crypto' ;
10+ import { PassThrough , pipeline } from 'node:stream' ;
11+ import { promisify } from 'node:util' ;
912import path from 'node:path' ;
1013import { fileURLToPath } from 'node:url' ;
1114import {
@@ -24,11 +27,12 @@ const XCTEST_AGENT_SCHEME_NAME = 'HarnessXCTestAgent';
2427const XCTEST_AGENT_PORT_ENV = 'HARNESS_XCTEST_AGENT_PORT' ;
2528const 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 ;
2831const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 5_000 ;
2932const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250 ;
3033const HARNESS_DIRNAME = '.harness' ;
3134const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent' ;
35+ const pipelineAsync = promisify ( pipeline ) ;
3236
3337type 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+
379430export 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 ;
0 commit comments