You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

652 lines
24 KiB

/**
* @fileoverview iOS test runner for Capacitor-based mobile app
*
* This script handles the build and testing of the iOS app using Xcode's
* command-line tools. It ensures the app is properly synced with the latest
* web build and runs the test suite on a specified iOS simulator.
*
* Process flow:
* 1. Sync Capacitor project with latest web build
* 2. Build app for iOS simulator
* 3. Run XCTest suite
*
* Prerequisites:
* - macOS operating system
* - Xcode installed with command line tools
* - iOS simulator available
* - Capacitor iOS platform added to project
* - Valid iOS development certificates
*
* Exit codes:
* - 0: Tests completed successfully
* - 1: Build or test failure
*
* @example
* // Run directly
* node scripts/test-ios.js
*
* // Run via npm script
* npm run test:ios
*
* @requires child_process
* @requires path
* @requires fs
*
* @author TimeSafari Team
* @license MIT
*/
const { execSync } = require('child_process');
const { join } = require('path');
const { existsSync, mkdirSync, appendFileSync, readFileSync } = require('fs');
// Format date as YYYY-MM-DD-HHMMSS
const getLogFileName = () => {
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
return `build_logs/ios-build-${date}-${time}.log`;
};
// Create logger function
const createLogger = (logFile) => {
return (message) => {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
console.log(message);
appendFileSync(logFile, logMessage);
};
};
// Check for iOS simulator
const checkSimulator = async (log) => {
log('🔍 Checking for iOS simulator...');
const simulatorsOutput = execSync('xcrun simctl list devices available -j').toString();
const simulatorsData = JSON.parse(simulatorsOutput);
// Get all available devices/simulators with their UDIDs
const allDevices = [];
// Process all runtime groups (iOS versions)
Object.entries(simulatorsData.devices).forEach(([runtime, devices]) => {
devices.forEach(device => {
allDevices.push({
name: device.name,
udid: device.udid,
state: device.state,
runtime: runtime,
isIphone: device.name.includes('iPhone'),
});
});
});
// Check for booted simulators first
const bootedDevices = allDevices.filter(device => device.state === 'Booted');
if (bootedDevices.length > 0) {
log(`📱 Found ${bootedDevices.length} running simulator(s): ${bootedDevices.map(d => d.name).join(', ')}`);
return bootedDevices;
}
// No booted devices found, try to boot one
log('⚠️ No running iOS simulator found. Attempting to boot one...');
// Prefer iPhone devices, especially newer models
const preferredDevices = [
'iPhone 15', 'iPhone 14', 'iPhone 13', 'iPhone 12', 'iPhone', // Prefer newer iPhones first
'iPad' // Then iPads if no iPhones available
];
let deviceToLaunch = null;
// Try to find a device from our preferred list
for (const preferredName of preferredDevices) {
const matchingDevices = allDevices.filter(device =>
device.name.includes(preferredName) && device.state === 'Shutdown');
if (matchingDevices.length > 0) {
// Sort by runtime to prefer newer iOS versions
matchingDevices.sort((a, b) => b.runtime.localeCompare(a.runtime));
deviceToLaunch = matchingDevices[0];
break;
}
}
// If no preferred device found, take any available device
if (!deviceToLaunch && allDevices.length > 0) {
const availableDevices = allDevices.filter(device => device.state === 'Shutdown');
if (availableDevices.length > 0) {
deviceToLaunch = availableDevices[0];
}
}
if (!deviceToLaunch) {
throw new Error('No available iOS simulators found. Please create a simulator in Xcode first.');
}
// Boot the selected simulator
log(`🚀 Booting iOS simulator: ${deviceToLaunch.name} (${deviceToLaunch.runtime})`);
execSync(`xcrun simctl boot ${deviceToLaunch.udid}`);
// Wait for simulator to fully boot
log('⏳ Waiting for simulator to boot completely...');
// Give the simulator time to fully boot before proceeding
await new Promise(resolve => setTimeout(resolve, 10000));
log(`✅ Successfully booted simulator: ${deviceToLaunch.name}`);
return [{ name: deviceToLaunch.name, udid: deviceToLaunch.udid }];
};
// Verify Xcode installation
const verifyXcodeInstallation = (log) => {
log('🔍 Checking Xcode installation...');
try {
execSync('xcode-select -p');
log('✅ Xcode command line tools found');
} catch (error) {
throw new Error('Xcode command line tools not found. Please install Xcode first.');
}
};
// Generate test data using generate_data.ts
const generateTestData = async (log) => {
log('🔄 Generating test data...');
// Check if test-scripts directory exists
if (!existsSync('test-scripts')) {
log('⚠️ test-scripts directory not found');
log('⚠️ Current directory: ' + process.cwd());
// List directories to help debug
const { readdirSync } = require('fs');
log('📂 Directories in current path:');
try {
const files = readdirSync('.');
files.forEach(file => {
const isDir = existsSync(file) && require('fs').statSync(file).isDirectory();
log(`${isDir ? '📁' : '📄'} ${file}`);
});
} catch (err) {
log(`⚠️ Error listing directory: ${err.message}`);
}
} else {
log('✅ Found test-scripts directory');
// Check if generate_data.ts exists
if (existsSync('test-scripts/generate_data.ts')) {
log('✅ Found generate_data.ts');
} else {
log('⚠️ generate_data.ts not found in test-scripts directory');
// List files in test-scripts to help debug
const { readdirSync } = require('fs');
log('📂 Files in test-scripts:');
try {
const files = readdirSync('test-scripts');
files.forEach(file => {
log(`📄 ${file}`);
});
} catch (err) {
log(`⚠️ Error listing test-scripts: ${err.message}`);
}
}
}
// Create .generated directory if it doesn't exist
if (!existsSync('.generated')) {
log('📁 Creating .generated directory');
mkdirSync('.generated', { recursive: true });
}
try {
// Try to generate test data using the script
log('🔄 Running test data generation script...');
execSync('npx ts-node test-scripts/generate_data.ts', { stdio: 'inherit' });
log('✅ Test data generation script completed');
// Verify the generated files exist
const requiredFiles = [
'.generated/test-env.json',
'.generated/claim_details.json',
'.generated/contacts.json'
];
log('🔍 Verifying generated files:');
for (const file of requiredFiles) {
if (!existsSync(file)) {
log(`⚠️ Required file ${file} was not generated`);
throw new Error(`Required file ${file} was not generated`);
} else {
log(`${file} exists`);
}
}
} catch (error) {
log(`⚠️ Failed to generate test data: ${error.message}`);
log('⚠️ Creating fallback test data...');
// Create minimal fallback test data
const fallbackTestEnv = {
"CONTACT1_DID": "did:example:123456789",
"APP_URL": "https://app.timesafari.example"
};
const fallbackClaimDetails = {
"claim_id": "claim_12345",
"title": "Test Claim",
"description": "This is a test claim"
};
const fallbackContacts = [
{
"id": "contact1",
"name": "Test Contact",
"did": "did:example:123456789"
}
];
// Use writeFileSync to overwrite any existing files
const { writeFileSync } = require('fs');
writeFileSync('.generated/test-env.json', JSON.stringify(fallbackTestEnv, null, 2));
writeFileSync('.generated/claim_details.json', JSON.stringify(fallbackClaimDetails, null, 2));
writeFileSync('.generated/contacts.json', JSON.stringify(fallbackContacts, null, 2));
log('✅ Fallback test data created');
// Verify files were created
const requiredFiles = [
'.generated/test-env.json',
'.generated/claim_details.json',
'.generated/contacts.json'
];
log('🔍 Verifying fallback files:');
for (const file of requiredFiles) {
if (!existsSync(file)) {
log(`⚠️ Failed to create ${file}`);
} else {
log(`✅ Created ${file}`);
}
}
}
};
// Build web assets
const buildWebAssets = async (log) => {
log('🌐 Building web assets...');
execSync('rm -rf dist', { stdio: 'inherit' });
execSync('npm run build:web', { stdio: 'inherit' });
execSync('npm run build:capacitor', { stdio: 'inherit' });
log('✅ Web assets built successfully');
};
// Configure iOS project
const configureIosProject = async (log) => {
log('📱 Syncing Capacitor project...');
try {
execSync('npx cap sync ios', { stdio: 'inherit' });
log('✅ Capacitor sync completed');
} catch (error) {
log('⚠️ Capacitor sync encountered issues. Attempting to continue...');
}
// Register URL scheme for deeplink tests
log('🔗 Configuring URL scheme for deeplink tests...');
if (checkAndRegisterUrlScheme(log)) {
log('✅ URL scheme configuration completed');
}
log('⚙️ Installing CocoaPods dependencies...');
try {
// Try to run pod install normally first
execSync('cd ios/App && pod install', { stdio: 'inherit' });
} catch (error) {
// If that fails, try using sudo (requires password)
log('⚠️ CocoaPods installation failed. Trying with sudo...');
try {
execSync('cd ios/App && sudo pod install', { stdio: 'inherit' });
} catch (sudoError) {
// If both methods fail, alert the user
log('❌ CocoaPods installation failed.');
log('Please run one of the following commands manually:');
log('1. cd ios/App && pod install');
log('2. cd ios/App && sudo pod install');
log('3. Install CocoaPods through Homebrew: brew install cocoapods');
throw new Error('CocoaPods installation failed. See log for details.');
}
}
log('✅ CocoaPods installation completed');
};
// Build and test iOS project
const buildAndTestIos = async (log, simulator) => {
const simulatorName = simulator[0].name;
log('🏗️ Building iOS project...');
execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' });
log('✅ Xcode clean completed');
log(`🏗️ Building for simulator: ${simulatorName}`);
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,name=${simulatorName}"`, { stdio: 'inherit' });
log('✅ Xcode build completed');
// Check if the project is configured for testing by querying the scheme capabilities
try {
log(`🧪 Checking if scheme is configured for testing`);
const schemeInfo = execSync(`cd ios/App && xcodebuild -scheme App -showBuildSettings | grep TEST`).toString();
if (schemeInfo.includes('ENABLE_TESTABILITY = YES')) {
log(`🧪 Attempting to run tests on simulator: ${simulatorName}`);
try {
execSync(`cd ios/App && xcodebuild test -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,name=${simulatorName}"`, { stdio: 'inherit' });
log('✅ iOS tests completed successfully');
} catch (testError) {
log(`⚠️ Tests failed or scheme not properly configured for testing: ${testError.message}`);
log('⚠️ This is normal if no test targets have been added to the project');
log('⚠️ Skipping test step and continuing with the app launch');
}
} else {
log('⚠️ Project does not have testing enabled in build settings');
log('⚠️ Skipping test step and continuing with the app launch');
}
} catch (error) {
log('⚠️ Unable to determine if testing is configured');
log('⚠️ Skipping test step and continuing with the app launch');
}
};
// Run the app
const runIosApp = async (log, simulator) => {
const simulatorName = simulator[0].name;
const simulatorUdid = simulator[0].udid;
log(`📱 Running app in simulator: ${simulatorName} (${simulatorUdid})...`);
// Use the --target parameter to specify the device directly, avoiding the UI prompt
execSync(`npx cap run ios --target="${simulatorUdid}"`, { stdio: 'inherit' });
log('✅ App launched successfully');
};
/**
* Run deeplink tests
* Optionally tests deeplinks if the test data is available
*
* @param {function} log - Logging function
* @returns {Promise<void>}
*/
const runDeeplinkTests = async (log) => {
log('🔗 Starting deeplink tests...');
// Register URL scheme if needed
checkAndRegisterUrlScheme(log);
// Check if test data files exist first
const requiredFiles = [
'.generated/test-env.json',
'.generated/claim_details.json',
'.generated/contacts.json'
];
for (const file of requiredFiles) {
if (!existsSync(file)) {
log(`⚠️ Required file ${file} does not exist`);
log('⚠️ Skipping deeplink tests');
return;
}
}
try {
// Load test data
log('📂 Loading test data from .generated directory');
let testEnv, claimDetails, contacts;
try {
const testEnvContent = readFileSync('.generated/test-env.json', 'utf8');
testEnv = JSON.parse(testEnvContent);
log('✅ Loaded test-env.json');
} catch (error) {
log(`⚠️ Failed to load test-env.json: ${error.message}`);
return;
}
try {
const claimDetailsContent = readFileSync('.generated/claim_details.json', 'utf8');
claimDetails = JSON.parse(claimDetailsContent);
log('✅ Loaded claim_details.json');
} catch (error) {
log(`⚠️ Failed to load claim_details.json: ${error.message}`);
return;
}
try {
const contactsContent = readFileSync('.generated/contacts.json', 'utf8');
contacts = JSON.parse(contactsContent);
log('✅ Loaded contacts.json');
} catch (error) {
log(`⚠️ Failed to load contacts.json: ${error.message}`);
return;
}
// Check if the app URL scheme is registered in the simulator
log('🔍 Checking if URL scheme is registered in simulator...');
try {
// Attempt to open a simple URL with the scheme
execSync(`xcrun simctl openurl booted "timesafari://test"`, { stdio: 'pipe' });
log('✅ URL scheme is registered and working');
} catch (error) {
const errorMessage = error.message || '';
// Check for the specific error code that indicates an unregistered URL scheme
if (errorMessage.includes('OSStatus error -10814') || errorMessage.includes('NSOSStatusErrorDomain, code=-10814')) {
log('⚠️ URL scheme "timesafari://" is not registered in the app or app is not running');
log('⚠️ The scheme was added to Info.plist but the app may need to be rebuilt');
log('⚠️ Trying to continue with tests, but they may fail');
}
}
// Test URLs
const deeplinkTests = [
{
url: `timesafari://claim/${claimDetails.claim_id}`,
description: 'Claim view'
},
{
url: `timesafari://claim-cert/${claimDetails.claim_id}`,
description: 'Claim certificate view'
},
{
url: `timesafari://claim-add-raw/${claimDetails.claim_id}`,
description: 'Raw claim addition'
},
{
url: 'timesafari://did/test',
description: 'DID view with test identifier'
},
{
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
description: 'DID view with contact DID'
},
{
url: `timesafari://contact-edit/${testEnv.CONTACT1_DID}`,
description: 'Contact editing'
},
{
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
description: 'Contacts import'
}
];
// Execute each test
let testsCompleted = 0;
let testsSkipped = 0;
for (const test of deeplinkTests) {
try {
log(`\n🔗 Testing deeplink: ${test.description}`);
log(`URL: ${test.url}`);
execSync(`xcrun simctl openurl booted "${test.url}"`, { stdio: 'pipe' });
log(`✅ Successfully executed: ${test.description}`);
testsCompleted++;
// Wait between tests
await new Promise(resolve => setTimeout(resolve, 5000));
} catch (deeplinkError) {
const errorMessage = deeplinkError.message || '';
// Handle specific error for URL scheme not registered
if (errorMessage.includes('OSStatus error -10814') || errorMessage.includes('NSOSStatusErrorDomain, code=-10814')) {
log(`⚠️ URL scheme not properly handled: ${test.description}`);
testsSkipped++;
} else {
log(`⚠️ Failed to execute deeplink test: ${test.description}`);
log(`⚠️ Error: ${errorMessage}`);
}
log('⚠️ Continuing with next test...');
}
}
log(`✅ Deeplink tests completed: ${testsCompleted} successful, ${testsSkipped} skipped`);
if (testsSkipped > 0) {
log('\n📝 Note about skipped tests:');
log('1. The app needs to have the URL scheme registered in Info.plist');
log('2. The app needs to be rebuilt after registering the URL scheme');
log('3. The app must be running in the foreground for deeplink tests to work');
log('4. If these conditions are met and tests still fail, check URL handling in the app code');
}
} catch (error) {
log(`❌ Deeplink tests setup failed: ${error.message}`);
log('⚠️ Deeplink tests might be unavailable or test data is missing');
// Don't rethrow the error to prevent halting the process
}
};
// Check and register URL scheme if needed
const checkAndRegisterUrlScheme = (log) => {
log('🔍 Checking if URL scheme is registered in Info.plist...');
const infoPlistPath = 'ios/App/App/Info.plist';
// Check if Info.plist exists
if (!existsSync(infoPlistPath)) {
log('⚠️ Info.plist not found at: ' + infoPlistPath);
return false;
}
// Read Info.plist content
const infoPlistContent = readFileSync(infoPlistPath, 'utf8');
// Check if URL scheme is already registered
if (infoPlistContent.includes('<string>timesafari</string>')) {
log('✅ URL scheme "timesafari://" is already registered in Info.plist');
return true;
}
log('⚠️ URL scheme "timesafari://" is not registered in Info.plist');
log('⚠️ Attempting to register the URL scheme automatically...');
try {
// Look for the closing dict tag to insert our URL types
const closingDictIndex = infoPlistContent.lastIndexOf('</dict>');
if (closingDictIndex === -1) {
log('⚠️ Could not find closing dict tag in Info.plist');
return false;
}
// Create URL types entry
const urlTypesEntry = `
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>`;
// Insert URL types entry before closing dict
const updatedPlistContent =
infoPlistContent.substring(0, closingDictIndex) +
urlTypesEntry +
infoPlistContent.substring(closingDictIndex);
// Write updated content back to Info.plist
const { writeFileSync } = require('fs');
writeFileSync(infoPlistPath, updatedPlistContent, 'utf8');
log('✅ URL scheme "timesafari://" registered in Info.plist');
log('⚠️ You will need to rebuild the app for changes to take effect');
return true;
} catch (error) {
log(`⚠️ Failed to register URL scheme: ${error.message}`);
return false;
}
};
/**
* Runs the complete iOS test suite including build and testing
*
* The function performs the following steps:
* 1. Syncs the Capacitor project with latest web build
* 2. Builds the app using xcodebuild
* 3. Optionally runs tests if configured
* 4. Launches the app in the simulator
*
* If no simulator is running, it automatically selects and boots one.
*
* @async
* @throws {Error} If any step in the build process fails
*
* @example
* runIosTests().catch(error => {
* console.error('Test execution failed:', error);
* process.exit(1);
* });
*/
async function runIosTests() {
// Create build_logs directory if it doesn't exist
if (!existsSync('build_logs')) {
mkdirSync('build_logs');
}
const logFile = getLogFileName();
const log = createLogger(logFile);
try {
log('🚀 Starting iOS build and test process...');
// Generate test data first
await generateTestData(log);
// Verify Xcode installation
verifyXcodeInstallation(log);
// Check for simulator or boot one if needed
const simulator = await checkSimulator(log);
// Build web assets and configure iOS project
await buildWebAssets(log);
await configureIosProject(log);
// Build and test using the selected simulator
await buildAndTestIos(log, simulator);
// Run the app in the simulator
await runIosApp(log, simulator);
// Run deeplink tests after app is installed
await runDeeplinkTests(log);
log('🎉 iOS build and test process completed successfully');
log(`📝 Full build log available at: ${logFile}`);
} catch (error) {
log(`❌ iOS tests failed: ${error.message}`);
log(`📝 Check build log for details: ${logFile}`);
process.exit(1);
}
}
// Execute the test suite
runIosTests();