Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/eas-simulator-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eas-cli': minor
---

Show EAS Simulator minutes in `eas account:usage`.
149 changes: 149 additions & 0 deletions packages/eas-cli/src/commandUtils/__tests__/usageUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ function createMockFullUsageData(
bandwidthLimit: number;
buildOverageCost: number;
updateOverageCost: number;
simulator: {
planValue: number;
limit: number;
iosMinutes: number;
androidMinutes: number;
overageMinutes: number;
overageCost: number;
jobTypeBreakdown?: { workflows: number; simulator: number; other: number };
};
}> = {}
): AccountFullUsageData {
const {
Expand All @@ -35,6 +44,7 @@ function createMockFullUsageData(
bandwidthLimit = 10,
buildOverageCost = 0,
updateOverageCost = 0,
simulator,
} = overrides;

const now = new Date();
Expand Down Expand Up @@ -167,6 +177,72 @@ function createMockFullUsageData(
: [],
totalCost: updateOverageCost,
},
EAS_SIMULATOR: {
unavailableServiceUsageMetrics: [],
__typename: 'UsageMetricTotal',
id: 'simulator-metric-id',
billingPeriod: {
__typename: 'BillingPeriod',
id: 'billing-period-id',
start: startOfMonth.toISOString(),
end: endOfMonth.toISOString(),
anchor: startOfMonth.toISOString(),
},
planMetrics: simulator
? [
{
__typename: 'EstimatedUsage',
id: 'simulator-plan-metric-id',
service: EasService.Simulator,
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.planValue,
limit: simulator.limit,
jobTypeBreakdown: simulator.jobTypeBreakdown
? { __typename: 'EstimatedUsageJobTypeBreakdown', ...simulator.jobTypeBreakdown }
: null,
},
]
: [],
overageMetrics:
simulator && simulator.overageMinutes > 0
? [
{
__typename: 'EstimatedOverageAndCost',
id: 'simulator-overage-id',
service: EasService.Simulator,
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.overageMinutes,
limit: simulator.limit,
totalCost: simulator.overageCost,
},
]
: [],
totalCost: simulator?.overageCost ?? 0,
},
IOS_SIMULATOR_MINUTES: simulator
? [
{
__typename: 'AccountUsageMetric',
id: 'ios-simulator-minutes-id',
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.iosMinutes,
},
]
: [],
ANDROID_SIMULATOR_MINUTES: simulator
? [
{
__typename: 'AccountUsageMetric',
id: 'android-simulator-minutes-id',
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.androidMinutes,
},
]
: [],
},
};
}
Expand Down Expand Up @@ -240,6 +316,14 @@ describe('calculateBillingPeriodDays', () => {
});

describe('extractUsageData', () => {
it('does not estimate a bill when Simulator usage is unavailable', () => {
const data = createMockFullUsageData();
data.usageMetrics.EAS_SIMULATOR.unavailableServiceUsageMetrics = [
{ service: EasService.Simulator },
];
expect(() => extractUsageData(data)).toThrow('Simulator usage is temporarily unavailable');
});

it('correctly extracts usage data from API response', () => {
const mockData = createMockFullUsageData({
buildValue: 25,
Expand Down Expand Up @@ -271,6 +355,71 @@ describe('extractUsageData', () => {
expect(result.totalOverageCostCents).toBe(2000);
});

it('omits simulator usage when the account has no simulator metric', () => {
const result = extractUsageData(createMockFullUsageData());

expect(result.simulator).toBeUndefined();
});

it('extracts simulator minutes and adds the overage to the total cost', () => {
const mockData = createMockFullUsageData({
buildValue: 60,
buildLimit: 50,
buildOverageCost: 2000,
simulator: {
planValue: 60,
limit: 60,
iosMinutes: 50.5,
androidMinutes: 14,
overageMinutes: 4.5,
overageCost: 68,
},
});

const result = extractUsageData(mockData);

expect(result.simulator).toEqual({
minutes: {
name: 'Simulator minutes',
planValue: 60,
limit: 60,
percentUsed: 100,
overageValue: 4.5,
overageCost: 68,
unit: 'minutes',
maxDecimals: 1,
},
iosMinutes: 50.5,
androidMinutes: 14,
jobTypeBreakdown: undefined,
overageCostCents: 68,
});
expect(result.totalOverageCostCents).toBe(2068);
expect(result.estimatedBillCents).toBe(1900 + 2068);
});

it('labels the Free plan minute pool and keeps the job type breakdown', () => {
const mockData = createMockFullUsageData({
subscriptionName: 'Free',
simulator: {
planValue: 45,
limit: 60,
iosMinutes: 5,
androidMinutes: 0,
overageMinutes: 0,
overageCost: 0,
jobTypeBreakdown: { workflows: 40, simulator: 5, other: 0 },
},
});

const result = extractUsageData(mockData);

expect(result.simulator?.minutes.name).toBe('CI/CD and Simulator minutes');
expect(result.simulator?.minutes.percentUsed).toBe(75);
expect(result.simulator?.jobTypeBreakdown).toEqual({ workflows: 40, simulator: 5, other: 0 });
expect(result.totalOverageCostCents).toBe(0);
});

it('handles Free plan', () => {
const mockData = createMockFullUsageData({
subscriptionName: 'Free',
Expand Down
82 changes: 81 additions & 1 deletion packages/eas-cli/src/commandUtils/usageUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AppPlatform,
EasBuildBillingResourceClass,
EasService,
EasServiceMetric,
UsageMetricType,
} from '../graphql/generated';
Expand All @@ -15,6 +16,21 @@ export interface UsageMetricDisplay {
overageValue: number;
overageCost: number;
unit?: string;
maxDecimals?: number;
}

export interface SimulatorJobTypeBreakdown {
workflows: number;
simulator: number;
other: number;
}

export interface SimulatorUsageDisplay {
minutes: UsageMetricDisplay;
iosMinutes: number;
androidMinutes: number;
jobTypeBreakdown?: SimulatorJobTypeBreakdown;
overageCostCents: number;
}

export interface BuildOverageByWorkerSize {
Expand Down Expand Up @@ -50,6 +66,7 @@ export interface UsageDisplayData {
bandwidth: UsageMetricDisplay;
overageCostCents: number;
};
simulator?: SimulatorUsageDisplay;
totalOverageCostCents: number;
estimatedBillCents: number;
recurringCents: number | null;
Expand Down Expand Up @@ -108,6 +125,9 @@ export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
LARGE_ANDROID_BUILDS,
MEDIUM_IOS_BUILDS,
LARGE_IOS_BUILDS,
EAS_SIMULATOR,
IOS_SIMULATOR_MINUTES,
ANDROID_SIMULATOR_MINUTES,
} = usageMetrics;

// Find build metrics
Expand Down Expand Up @@ -201,9 +221,17 @@ export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
const bandwidthOverageValue = bandwidthOverage?.value ?? 0;
const bandwidthLimit = bandwidthMetric?.limit ?? 0;

const simulator = extractSimulatorUsage(
EAS_SIMULATOR,
IOS_SIMULATOR_MINUTES?.[0]?.value ?? 0,
ANDROID_SIMULATOR_MINUTES?.[0]?.value ?? 0
);

const buildOverageCostCents = EAS_BUILD.totalCost;
const updateOverageCostCents = EAS_UPDATE.totalCost;
const totalOverageCostCents = buildOverageCostCents + updateOverageCostCents;
const simulatorOverageCostCents = simulator?.overageCostCents ?? 0;
const totalOverageCostCents =
buildOverageCostCents + updateOverageCostCents + simulatorOverageCostCents;

// Calculate total overage count for display
const totalOverageBuilds = overagesByWorkerSize.reduce((sum, o) => sum + o.count, 0);
Expand Down Expand Up @@ -272,12 +300,64 @@ export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
},
overageCostCents: updateOverageCostCents,
},
simulator,
totalOverageCostCents,
estimatedBillCents: (subscription?.recurringCents ?? 0) + totalOverageCostCents,
recurringCents: subscription?.recurringCents ?? null,
};
}

// Accounts without EAS Simulator get no SIMULATOR_USAGE plan metric, so the section is hidden.
// On the Free plan the metric is the minute pool shared with Workflows, split by jobTypeBreakdown.
function extractSimulatorUsage(
simulatorTotal: AccountFullUsageData['usageMetrics']['EAS_SIMULATOR'],
iosMinutes: number,
androidMinutes: number
): SimulatorUsageDisplay | undefined {
if (
simulatorTotal.unavailableServiceUsageMetrics.some(
metric => metric.service === EasService.Simulator
)
) {
throw new Error(
'Simulator usage is temporarily unavailable, so the bill cannot be estimated. Try again in a few minutes.'
);
}
const planMetric = simulatorTotal.planMetrics.find(
m => m.serviceMetric === EasServiceMetric.SimulatorUsage
);
if (!planMetric) {
return undefined;
}
const overage = simulatorTotal.overageMetrics.find(
m => m.serviceMetric === EasServiceMetric.SimulatorUsage
);
const jobTypeBreakdown = planMetric.jobTypeBreakdown
? {
workflows: planMetric.jobTypeBreakdown.workflows,
simulator: planMetric.jobTypeBreakdown.simulator,
other: planMetric.jobTypeBreakdown.other,
}
: undefined;

return {
minutes: {
name: jobTypeBreakdown ? 'CI/CD and Simulator minutes' : 'Simulator minutes',
planValue: planMetric.value,
limit: planMetric.limit,
percentUsed: calculatePercentUsed(planMetric.value, planMetric.limit),
overageValue: overage?.value ?? 0,
overageCost: overage?.totalCost ?? 0,
unit: 'minutes',
maxDecimals: 1,
},
iosMinutes,
androidMinutes,
jobTypeBreakdown,
overageCostCents: simulatorTotal.totalCost,
};
}

export function calculateBillingPeriodInfo(data: UsageDisplayData): {
daysRemaining: number;
daysElapsed: number;
Expand Down
Loading
Loading