Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/build-tools/src/__tests__/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface BuildContextParams {
runtimePlatform?: BuildRuntimePlatform;
projectSourceDirectory?: string;
projectTargetDirectory?: string;
buildLogsDirectory?: string;
relativeWorkingDirectory?: string;
staticContextContent?: Record<string, any>;
}
Expand All @@ -53,6 +54,7 @@ export function createStepContextMock({
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
buildLogsDirectory,
relativeWorkingDirectory,
staticContextContent,
}: BuildContextParams = {}): BuildStepContext {
Expand All @@ -63,6 +65,7 @@ export function createStepContextMock({
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
buildLogsDirectory,
relativeWorkingDirectory,
staticContextContent,
});
Expand All @@ -78,6 +81,7 @@ export function createGlobalContextMock({
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
buildLogsDirectory,
relativeWorkingDirectory,
staticContextContent,
}: BuildContextParams = {}): BuildStepGlobalContext {
Expand All @@ -92,7 +96,7 @@ export function createGlobalContextMock({
relativeWorkingDirectory
? path.resolve(resolvedProjectTargetDirectory, relativeWorkingDirectory)
: resolvedProjectTargetDirectory,
'/non/existent/dir',
buildLogsDirectory ?? '/non/existent/dir',
staticContextContent ?? {}
),
skipCleanup ?? false
Expand Down
35 changes: 35 additions & 0 deletions packages/build-tools/src/steps/__tests__/easFunctionGroups.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Platform } from '@expo/eas-build-job';

import { createGlobalContextMock } from '../../__tests__/utils/context';
import { getEasFunctionGroups } from '../easFunctionGroups';
import { createEasMaestroTestFunctionGroup } from '../functionGroups/maestroTest';

describe(getEasFunctionGroups, () => {
it('includes eas/maestro_test for non-build jobs', () => {
Expand All @@ -24,4 +28,35 @@ describe(getEasFunctionGroups, () => {
);
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
});

it('collects Android emulator logs before uploading Maestro results', () => {
const ctx = {
job: {
platform: Platform.ANDROID,
},
} as unknown as Parameters<typeof createEasMaestroTestFunctionGroup>[0];
const globalCtx = createGlobalContextMock();

const steps = createEasMaestroTestFunctionGroup(ctx).createBuildStepsFromFunctionGroupCall(
globalCtx,
{
callInputs: {
flow_path: 'maestro/home.yml\nmaestro/login.yml',
},
}
);

const stepDisplayNames = steps.map(step => step.displayName);
expect(stepDisplayNames).toEqual([
'Install Maestro',
'Start Android Emulator',
'Install app to Emulator',
'maestro test maestro/home.yml',
'maestro test maestro/login.yml',
'Collect Android emulator logs',
'Upload Maestro test results',
]);
expect(steps[5].ifCondition).toBe('${ always() }');
expect(steps[6].ifCondition).toBe('${ always() }');
});
});
2 changes: 2 additions & 0 deletions packages/build-tools/src/steps/easFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BuildFunction } from '@expo/steps';

import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion';
import { createCheckoutBuildFunction } from './functions/checkout';
import { createCollectEmulatorLogsBuildFunction } from './functions/collectEmulatorLogs';
import { configureAndroidVersionFunction } from './functions/configureAndroidVersion';
import { configureEASUpdateIfInstalledFunction } from './functions/configureEASUpdateIfInstalled';
import { configureIosCredentialsFunction } from './functions/configureIosCredentials';
Expand Down Expand Up @@ -49,6 +50,7 @@ import { CustomBuildContext } from '../customBuildContext';
export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
const functions = [
createCheckoutBuildFunction(),
createCollectEmulatorLogsBuildFunction(),
createDownloadArtifactFunction(),
createUploadArtifactBuildFunction(ctx),
createSetUpNpmrcBuildFunction(),
Expand Down
7 changes: 7 additions & 0 deletions packages/build-tools/src/steps/functionGroups/maestroTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@expo/steps';

import { CustomBuildContext } from '../../customBuildContext';
import { createCollectEmulatorLogsBuildFunction } from '../functions/collectEmulatorLogs';
import { createInstallMaestroBuildFunction } from '../functions/installMaestro';
import { createStartAndroidEmulatorBuildFunction } from '../functions/startAndroidEmulator';
import { createStartIosSimulatorBuildFunction } from '../functions/startIosSimulator';
Expand Down Expand Up @@ -139,6 +140,12 @@ export function createEasMaestroTestFunctionGroup(
);
}

steps.push(
createCollectEmulatorLogsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
ifCondition: '${ always() }',
})
);

steps.push(
createUploadArtifactBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';

import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createCollectEmulatorLogsBuildFunction } from '../collectEmulatorLogs';

jest.unmock('fs');
jest.unmock('node:fs');

describe('createCollectEmulatorLogsBuildFunction', () => {
it('copies staged emulator logcat files from the build logs directory', async () => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'collect-emulator-logs-'));
try {
const buildLogsDirectory = path.join(tempDir, 'build-logs');
const destinationPath = path.join(tempDir, 'maestro-tests', 'emulator-logs');
const sourcePath = AndroidEmulatorUtils.getLogcatStagingDirectoryPath({ buildLogsDirectory });

await fs.promises.mkdir(sourcePath, { recursive: true });
await fs.promises.writeFile(path.join(sourcePath, '1111-emulator-5554.log'), 'first log\n');
await fs.promises.writeFile(path.join(sourcePath, '2222-emulator-5556.log'), 'second log\n');
await fs.promises.writeFile(path.join(sourcePath, '2222-emulator-5556.log.json'), '{}\n');

const collectEmulatorLogs = createCollectEmulatorLogsBuildFunction();
const step = collectEmulatorLogs.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger({ logToConsole: true }),
buildLogsDirectory,
}),
{
callInputs: {
destination_path: destinationPath,
},
}
);

await expect(step.executeAsync()).resolves.not.toThrow();

await expect(
fs.promises.readFile(path.join(destinationPath, '1111-emulator-5554.log'), 'utf-8')
).resolves.toBe('first log\n');
await expect(
fs.promises.readFile(path.join(destinationPath, '2222-emulator-5556.log'), 'utf-8')
).resolves.toBe('second log\n');
await expect(
fs.promises.access(path.join(destinationPath, '2222-emulator-5556.log.json'))
).rejects.toThrow();
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
});

it('collects streamed logcat output from a running emulator', async () => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'collect-emulator-logs-'));
const deviceName =
`android-emulator-logcat-e2e-${randomUUID().slice(0, 8)}` as AndroidVirtualDeviceName;
let emulatorPromise: Promise<unknown> | null = null;
let serialId: AndroidDeviceSerialId | null = null;

try {
const logger = createMockLogger({ logToConsole: true });
const buildLogsDirectory = path.join(tempDir, 'build-logs');
const destinationPath = path.join(tempDir, 'maestro-tests', 'emulator-logs');
const sourcePath = AndroidEmulatorUtils.getLogcatStagingDirectoryPath({ buildLogsDirectory });
const marker = `ENG-20762-${randomUUID()}`;

await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger,
});

const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);

const streamResult = await AndroidEmulatorUtils.startLogcatStreamingAsync({
serialId: startResult.serialId,
outputDir: sourcePath,
env: process.env,
logger,
});
expect(streamResult).not.toBeNull();

await AndroidEmulatorUtils.waitForReadyAsync({
serialId: startResult.serialId,
env: process.env,
});

await spawn(
'adb',
['-s', startResult.serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker],
{ env: process.env }
);

await retryAsync(
async () => {
const stagedLogFiles = (await fs.promises.readdir(sourcePath)).filter(entry =>
entry.endsWith('.log')
);
expect(stagedLogFiles.length).toBeGreaterThan(0);
const stagedLogPath = path.join(sourcePath, stagedLogFiles[0]);
const contents = await fs.promises.readFile(stagedLogPath, 'utf-8');
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in staged log yet.`);
}
},
{
logger,
retryOptions: {
retries: 10,
retryIntervalMs: 1_000,
},
}
);

const collectEmulatorLogs = createCollectEmulatorLogsBuildFunction();
const step = collectEmulatorLogs.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger,
buildLogsDirectory,
}),
{
callInputs: {
destination_path: destinationPath,
},
}
);

await expect(step.executeAsync()).resolves.not.toThrow();

const collectedLogFiles = (await fs.promises.readdir(destinationPath)).filter(entry =>
entry.endsWith('.log')
);
expect(collectedLogFiles.length).toBeGreaterThan(0);
const collectedLogPath = path.join(destinationPath, collectedLogFiles[0]);
await expect(fs.promises.readFile(collectedLogPath, 'utf-8')).resolves.toContain(marker);
} finally {
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
}
} catch (error) {
console.warn(
'Failed to clean up emulator during collectEmulatorLogs integration test',
error
);
}
if (emulatorPromise) {
await emulatorPromise;
}
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}, 180_000);
});
Loading
Loading