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
50 changes: 32 additions & 18 deletions src/commands/actors/info.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Actor, ActorTaggedBuild, Build, User } from 'apify-client';
import type { Actor, ActorChargeEvent, ActorTaggedBuild, Build, User } from 'apify-client';
import chalk from 'chalk';

import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
Expand All @@ -14,19 +14,20 @@ interface HydratedActorInfo extends Omit<Actor, 'taggedBuilds'> {
actorMaker?: User;
}

interface PricingInfo {
pricingModel: 'PRICE_PER_DATASET_ITEM' | 'FLAT_PRICE_PER_MONTH' | 'PAY_PER_EVENT' | 'FREE';
pricePerUnitUsd: number;
unitName: string;
startedAt: string;
createdAt: string;
apifyMarginPercentage: number;
notifiedAboutFutureChangeAt: string;
notifiedAboutChangeAt: string;
trialMinutes?: number;
pricingPerEvent?: {
actorChargeEvents: Record<string, { eventTitle: string; eventDescription: string; eventPriceUsd: number }>;
};
// apify-client's ActorChargeEvent declares eventPriceUsd as required and is missing
// eventTieredPricingUsd. Tiered pay-per-event pricing shipped on the platform after the SDK type
// was last updated; remodel locally until upstream catches up: https://github.com/apify/apify-client-js
type ChargeEventShape = Omit<ActorChargeEvent, 'eventPriceUsd'> & {
eventPriceUsd?: number;
eventTieredPricingUsd?: Record<string, { tieredEventPriceUsd: number }>;
};

// Event prices are routinely sub-cent (e.g. $0.005, $0.00079) so plain toFixed(2) rounds them to
// "$0.00" and hides the real cost. Use 2 decimals at >= $0.01, otherwise 2 significant figures.
function formatEventPrice(price: number): string {
if (price === 0) return '$0.00';
if (price >= 0.01) return `$${price.toFixed(2)}`;
return `$${Number(price.toPrecision(2))}`;
}

const eventTitleColumn = '\u200b';
Expand Down Expand Up @@ -210,7 +211,7 @@ export class ActorsInfoCommand extends ApifyCommand<typeof ActorsInfoCommand> {
}

// Pricing info
const pricingInfo = Reflect.get(actorInfo, 'pricingInfos') as PricingInfo[] | undefined;
const pricingInfo = actorInfo.pricingInfos;

if (pricingInfo?.length) {
// We only print the latest pricing info
Expand Down Expand Up @@ -245,10 +246,21 @@ export class ActorsInfoCommand extends ApifyCommand<typeof ActorsInfoCommand> {

const events = Object.values(latestPricingInfo.pricingPerEvent?.actorChargeEvents ?? {});

for (const eventInfo of events) {
for (const eventInfo of events as ChargeEventShape[]) {
const flat = eventInfo.eventPriceUsd;
const tiered = eventInfo.eventTieredPricingUsd;
let priceLabel: string;
if (typeof flat === 'number') {
priceLabel = formatEventPrice(flat);
} else if (tiered && Object.keys(tiered).length > 0) {
const minPrice = Math.min(...Object.values(tiered).map((t) => t.tieredEventPriceUsd));
priceLabel = `from ${formatEventPrice(minPrice)} (tiered)`;
} else {
priceLabel = 'N/A';
}
payPerEventTable.pushRow({
[eventTitleColumn]: eventInfo.eventTitle,
[eventPriceUsdColumn]: chalk.bold(`$${eventInfo.eventPriceUsd.toFixed(2)}`),
[eventPriceUsdColumn]: chalk.bold(priceLabel),
});
}

Expand All @@ -269,8 +281,10 @@ export class ActorsInfoCommand extends ApifyCommand<typeof ActorsInfoCommand> {
}

default: {
// Runtime fallback for pricing models the SDK union doesn't know about yet.
const unknownModel = (latestPricingInfo as { pricingModel: string }).pricingModel;
message.push(
`${chalk.yellow('Pricing information:')} ${chalk.bgGray(`Unknown pricing model (${chalk.yellow(latestPricingInfo.pricingModel)})`)}`,
`${chalk.yellow('Pricing information:')} ${chalk.bgGray(`Unknown pricing model (${chalk.yellow(unknownModel)})`)}`,
);
}
}
Expand Down
15 changes: 15 additions & 0 deletions test/e2e/commands/actors/info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,19 @@ describe('[e2e][api] actors info', () => {

expect(result.stdout).toContain('was not found');
});

// Regression test for https://github.com/apify/apify-cli/issues/1171
// The human-readable renderer crashed with "Cannot read properties of undefined (reading 'toFixed')"
// on PAY_PER_EVENT actors that use tiered pricing (eventTieredPricingUsd) instead of a flat
// eventPriceUsd. --json bypasses the renderer, so the bug only surfaces on the default output.
it('renders pricing for a tiered PAY_PER_EVENT actor without crashing', async () => {
const result = await runCli('apify', ['actors', 'info', 'lukaskrivka/google-maps-with-contact-details'], {
env: authEnv,
});

expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0);
expect(result.stderr).not.toContain('toFixed');
expect(result.stdout).toContain('Pricing information');
expect(result.stdout).toContain('Pay per event');
});
});
Loading