From f5f616cfcebb6c37e40bdcef2bf89041277aa389 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Tue, 7 Apr 2026 15:23:02 +0200 Subject: [PATCH 01/11] feat: add execution context to telemetry events Track exit code, duration, CI/AI agent environment, interactivity, and command retry detection in telemetry data for better CLI usage insights. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/command-framework/apify-command.ts | 13 +++++ src/lib/hooks/telemetry/detectEnvironment.ts | 50 ++++++++++++++++++++ src/lib/hooks/telemetry/trackEvent.ts | 9 ++++ src/lib/hooks/telemetry/useTelemetryState.ts | 29 ++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 src/lib/hooks/telemetry/detectEnvironment.ts diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 049359bd7..5419d44de 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -9,8 +9,10 @@ import widestLine from 'widest-line'; import wrapAnsi from 'wrap-ansi'; import { cachedStdinInput } from '../../entrypoints/_shared.js'; +import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js'; import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js'; import { trackEvent } from '../hooks/telemetry/trackEvent.js'; +import { checkAndUpdateLastCommand } from '../hooks/telemetry/useTelemetryState.js'; import { useCLIMetadata } from '../hooks/useCLIMetadata.js'; import { ProjectLanguage, useCwdProject } from '../hooks/useCwdProject.js'; import { error } from '../outputs.js'; @@ -220,6 +222,12 @@ export abstract class ApifyCommand; @@ -243,6 +251,7 @@ export abstract class ApifyCommand { + try { + const state = await useTelemetryState(); + const now = Date.now(); + + const wasRetried = + state.lastCommand === commandString && now - (state.lastCommandTimestamp ?? 0) < RETRY_WINDOW_MS; + + updateTelemetryState(state, (stateToUpdate) => { + stateToUpdate.lastCommand = commandString; + stateToUpdate.lastCommandTimestamp = now; + }); + + return wasRetried; + } catch { + return false; + } +} + export async function updateTelemetryEnabled(enabled: boolean) { const state = await useTelemetryState(); From 2211399e0d0cbbdd3b10519ebb9aa6ed30ef0bc2 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Tue, 7 Apr 2026 15:47:44 +0200 Subject: [PATCH 02/11] test: add unit tests for telemetry environment detection Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hooks/telemetry/detectEnvironment.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 test/lib/hooks/telemetry/detectEnvironment.test.ts diff --git a/test/lib/hooks/telemetry/detectEnvironment.test.ts b/test/lib/hooks/telemetry/detectEnvironment.test.ts new file mode 100644 index 000000000..af610c4b9 --- /dev/null +++ b/test/lib/hooks/telemetry/detectEnvironment.test.ts @@ -0,0 +1,87 @@ +import { detectAiAgent, detectCi, detectIsInteractive } from '../../../../src/lib/hooks/telemetry/detectEnvironment.js'; + +// `is-ci` (and its underlying `ci-info`) evaluates process.env at import time, +// so we mock it to control the return value per-test. +vi.mock('is-ci', () => ({ default: false })); + +describe('detectAiAgent', () => { + const agentEnvVars = [ + 'CLAUDECODE', + 'CLAUDE_CODE_ENTRYPOINT', + 'CURSOR_AGENT', + 'CLINE_ACTIVE', + 'CODEX_SANDBOX', + 'CODEX_THREAD_ID', + 'GEMINI_CLI', + 'OPENCODE', + 'OPENCLAW_SHELL', + ]; + + afterEach(() => { + for (const key of agentEnvVars) { + delete process.env[key]; + } + }); + + test('returns undefined when no agent env vars are set', () => { + expect(detectAiAgent()).toBeUndefined(); + }); + + test('returns correct agent for a known env var', () => { + process.env.GEMINI_CLI = '1'; + expect(detectAiAgent()).toBe('gemini_cli'); + }); + + test('returns first match when multiple agent env vars are set', () => { + process.env.CURSOR_AGENT = '1'; + process.env.GEMINI_CLI = '1'; + + // CURSOR_AGENT appears before GEMINI_CLI in the lookup table + expect(detectAiAgent()).toBe('cursor'); + }); +}); + +describe('detectCi', () => { + const ciProviderEnvVars = ['GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'CIRCLECI', 'BUILDKITE', 'TRAVIS']; + + afterEach(() => { + for (const key of ciProviderEnvVars) { + delete process.env[key]; + } + }); + + test('returns isCi false when not in CI', () => { + const result = detectCi(); + expect(result).toEqual({ isCi: false, ciProvider: undefined }); + }); + + test('returns known provider when in CI with recognized env var', async () => { + vi.resetModules(); + vi.doMock('is-ci', () => ({ default: true })); + + const { detectCi: detectCiFresh } = await import( + '../../../../src/lib/hooks/telemetry/detectEnvironment.js' + ); + + process.env.GITHUB_ACTIONS = 'true'; + expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'github_actions' }); + }); + + test('returns unknown provider when in CI but no recognized provider env var', async () => { + vi.resetModules(); + vi.doMock('is-ci', () => ({ default: true })); + + const { detectCi: detectCiFresh } = await import( + '../../../../src/lib/hooks/telemetry/detectEnvironment.js' + ); + + expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'unknown' }); + }); +}); + +describe('detectIsInteractive', () => { + test('returns false when stdin or stdout is not a TTY', () => { + // In test runners, stdio is typically not a TTY + expect(detectIsInteractive()).toBe(false); + }); +}); From eb1de06288799cc0faa1d0c7f60bd832a0512527 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 8 Apr 2026 10:05:55 +0200 Subject: [PATCH 03/11] refactor: use ci-info package for CI provider detection Replace manual CI_PROVIDER_ENV_VARS lookup with ci-info's built-in provider detection, which covers 50+ CI providers out of the box. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + src/lib/hooks/telemetry/detectEnvironment.ts | 21 +++---------------- .../hooks/telemetry/detectEnvironment.test.ts | 21 ++++++------------- 3 files changed, 10 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index 5c259df7a..1c7a50bcd 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "computer-name": "~0.1.0", "configparser": "~0.3.10", "cors": "~2.8.5", + "ci-info": "~4.4.0", "detect-indent": "~7.0.1", "es-toolkit": "^1.45.1", "escape-string-regexp": "~5.0.0", diff --git a/src/lib/hooks/telemetry/detectEnvironment.ts b/src/lib/hooks/telemetry/detectEnvironment.ts index ca5811143..c21cd0e36 100644 --- a/src/lib/hooks/telemetry/detectEnvironment.ts +++ b/src/lib/hooks/telemetry/detectEnvironment.ts @@ -1,4 +1,4 @@ -import isCI from 'is-ci'; +import ciInfo from 'ci-info'; const AI_AGENT_ENV_VARS: [string, string][] = [ ['CLAUDECODE', 'claude_code'], @@ -12,15 +12,6 @@ const AI_AGENT_ENV_VARS: [string, string][] = [ ['OPENCLAW_SHELL', 'openclaw'], ]; -const CI_PROVIDER_ENV_VARS: [string, string][] = [ - ['GITHUB_ACTIONS', 'github_actions'], - ['GITLAB_CI', 'gitlab'], - ['JENKINS_URL', 'jenkins'], - ['CIRCLECI', 'circle'], - ['BUILDKITE', 'buildkite'], - ['TRAVIS', 'travis'], -]; - export function detectAiAgent(): string | undefined { for (const [envVar, agent] of AI_AGENT_ENV_VARS) { if (process.env[envVar]) { @@ -32,17 +23,11 @@ export function detectAiAgent(): string | undefined { } export function detectCi(): { isCi: boolean; ciProvider: string | undefined } { - if (!isCI) { + if (!ciInfo.isCI) { return { isCi: false, ciProvider: undefined }; } - for (const [envVar, provider] of CI_PROVIDER_ENV_VARS) { - if (process.env[envVar]) { - return { isCi: true, ciProvider: provider }; - } - } - - return { isCi: true, ciProvider: 'unknown' }; + return { isCi: true, ciProvider: ciInfo.id?.toLowerCase() ?? 'unknown' }; } export function detectIsInteractive(): boolean { diff --git a/test/lib/hooks/telemetry/detectEnvironment.test.ts b/test/lib/hooks/telemetry/detectEnvironment.test.ts index af610c4b9..aaecc05b2 100644 --- a/test/lib/hooks/telemetry/detectEnvironment.test.ts +++ b/test/lib/hooks/telemetry/detectEnvironment.test.ts @@ -1,8 +1,8 @@ import { detectAiAgent, detectCi, detectIsInteractive } from '../../../../src/lib/hooks/telemetry/detectEnvironment.js'; -// `is-ci` (and its underlying `ci-info`) evaluates process.env at import time, +// `ci-info` evaluates process.env at import time, // so we mock it to control the return value per-test. -vi.mock('is-ci', () => ({ default: false })); +vi.mock('ci-info', () => ({ default: { isCI: false, id: null } })); describe('detectAiAgent', () => { const agentEnvVars = [ @@ -42,34 +42,25 @@ describe('detectAiAgent', () => { }); describe('detectCi', () => { - const ciProviderEnvVars = ['GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'CIRCLECI', 'BUILDKITE', 'TRAVIS']; - - afterEach(() => { - for (const key of ciProviderEnvVars) { - delete process.env[key]; - } - }); - test('returns isCi false when not in CI', () => { const result = detectCi(); expect(result).toEqual({ isCi: false, ciProvider: undefined }); }); - test('returns known provider when in CI with recognized env var', async () => { + test('returns provider id from ci-info when in CI', async () => { vi.resetModules(); - vi.doMock('is-ci', () => ({ default: true })); + vi.doMock('ci-info', () => ({ default: { isCI: true, id: 'GITHUB_ACTIONS' } })); const { detectCi: detectCiFresh } = await import( '../../../../src/lib/hooks/telemetry/detectEnvironment.js' ); - process.env.GITHUB_ACTIONS = 'true'; expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'github_actions' }); }); - test('returns unknown provider when in CI but no recognized provider env var', async () => { + test('returns unknown provider when in CI but ci-info has no id', async () => { vi.resetModules(); - vi.doMock('is-ci', () => ({ default: true })); + vi.doMock('ci-info', () => ({ default: { isCI: true, id: null } })); const { detectCi: detectCiFresh } = await import( '../../../../src/lib/hooks/telemetry/detectEnvironment.js' From 8436012f5990e54b9add4ead04df15b9a8d7869a Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 8 Apr 2026 10:09:23 +0200 Subject: [PATCH 04/11] fix: update lockfile for ci-info dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1c7a50bcd..1760fb984 100644 --- a/package.json +++ b/package.json @@ -85,11 +85,11 @@ "archiver": "~7.0.1", "axios": "^1.11.0", "chalk": "~5.6.0", + "ci-info": "~4.4.0", "cli-table3": "^0.6.5", "computer-name": "~0.1.0", "configparser": "~0.3.10", "cors": "~2.8.5", - "ci-info": "~4.4.0", "detect-indent": "~7.0.1", "es-toolkit": "^1.45.1", "escape-string-regexp": "~5.0.0", diff --git a/yarn.lock b/yarn.lock index 8c5652016..71ee4234e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,6 +2433,7 @@ __metadata: archiver: "npm:~7.0.1" axios: "npm:^1.11.0" chalk: "npm:~5.6.0" + ci-info: "npm:~4.4.0" cli-table3: "npm:^0.6.5" computer-name: "npm:~0.1.0" configparser: "npm:~0.3.10" @@ -3215,7 +3216,7 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.0.0, ci-info@npm:^4.1.0": +"ci-info@npm:^4.0.0, ci-info@npm:^4.1.0, ci-info@npm:~4.4.0": version: 4.4.0 resolution: "ci-info@npm:4.4.0" checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a From 876bc8e2c9dc0562a2f2da30d3ca016f51e9c1bd Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 8 Apr 2026 10:25:50 +0200 Subject: [PATCH 05/11] fix: format import statements in telemetry test Co-Authored-By: Claude Opus 4.6 (1M context) --- test/lib/hooks/telemetry/detectEnvironment.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/lib/hooks/telemetry/detectEnvironment.test.ts b/test/lib/hooks/telemetry/detectEnvironment.test.ts index aaecc05b2..ac0ddb408 100644 --- a/test/lib/hooks/telemetry/detectEnvironment.test.ts +++ b/test/lib/hooks/telemetry/detectEnvironment.test.ts @@ -51,9 +51,7 @@ describe('detectCi', () => { vi.resetModules(); vi.doMock('ci-info', () => ({ default: { isCI: true, id: 'GITHUB_ACTIONS' } })); - const { detectCi: detectCiFresh } = await import( - '../../../../src/lib/hooks/telemetry/detectEnvironment.js' - ); + const { detectCi: detectCiFresh } = await import('../../../../src/lib/hooks/telemetry/detectEnvironment.js'); expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'github_actions' }); }); @@ -62,9 +60,7 @@ describe('detectCi', () => { vi.resetModules(); vi.doMock('ci-info', () => ({ default: { isCI: true, id: null } })); - const { detectCi: detectCiFresh } = await import( - '../../../../src/lib/hooks/telemetry/detectEnvironment.js' - ); + const { detectCi: detectCiFresh } = await import('../../../../src/lib/hooks/telemetry/detectEnvironment.js'); expect(detectCiFresh()).toEqual({ isCi: true, ciProvider: 'unknown' }); }); From b5c61096611061ad5d02563cdce37fd1ac4fdaf8 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 8 Apr 2026 10:38:34 +0200 Subject: [PATCH 06/11] fix: skip telemetry notice when APIFY_CLI_DISABLE_TELEMETRY is set The telemetry opt-in notice was printed to stderr even when telemetry was disabled via env var, causing test assertions to fail by capturing the notice as the last error message instead of the actual validation error. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/hooks/telemetry/useTelemetryState.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/hooks/telemetry/useTelemetryState.ts b/src/lib/hooks/telemetry/useTelemetryState.ts index f67af0ab6..94fa13355 100644 --- a/src/lib/hooks/telemetry/useTelemetryState.ts +++ b/src/lib/hooks/telemetry/useTelemetryState.ts @@ -73,7 +73,10 @@ export async function useTelemetryState(): Promise { }); // First time we are tracking telemetry, so we want to notify user about it. - info({ message: telemetryWarningText }); + // Skip the notice if telemetry is disabled via env var — the user already opted out. + if (!process.env.APIFY_CLI_DISABLE_TELEMETRY || ['false', '0'].includes(process.env.APIFY_CLI_DISABLE_TELEMETRY)) { + info({ message: telemetryWarningText }); + } return useTelemetryState(); } From 91f904c20cbd2bfb4b031c05335679f842b6fe11 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 8 Apr 2026 10:46:29 +0200 Subject: [PATCH 07/11] fix: format telemetry state condition Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/hooks/telemetry/useTelemetryState.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/hooks/telemetry/useTelemetryState.ts b/src/lib/hooks/telemetry/useTelemetryState.ts index 94fa13355..ce6ed213f 100644 --- a/src/lib/hooks/telemetry/useTelemetryState.ts +++ b/src/lib/hooks/telemetry/useTelemetryState.ts @@ -74,7 +74,10 @@ export async function useTelemetryState(): Promise { // First time we are tracking telemetry, so we want to notify user about it. // Skip the notice if telemetry is disabled via env var — the user already opted out. - if (!process.env.APIFY_CLI_DISABLE_TELEMETRY || ['false', '0'].includes(process.env.APIFY_CLI_DISABLE_TELEMETRY)) { + if ( + !process.env.APIFY_CLI_DISABLE_TELEMETRY || + ['false', '0'].includes(process.env.APIFY_CLI_DISABLE_TELEMETRY) + ) { info({ message: telemetryWarningText }); } From 6cbeaa22bf3d3e03444e443ae7dc6de3192e91d5 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Thu, 9 Apr 2026 12:36:44 +0200 Subject: [PATCH 08/11] fix: skip telemetry data collection when telemetry is disabled Move flagsUsed, exitCode, durationMs, and wasRetried assignments inside the skipTelemetry guard to avoid unnecessary work. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/command-framework/apify-command.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 5419d44de..fc32080b5 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -338,13 +338,13 @@ export abstract class ApifyCommand Date: Thu, 9 Apr 2026 12:42:58 +0200 Subject: [PATCH 09/11] test: add tests for checkAndUpdateLastCommand telemetry helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../checkAndUpdateLastCommand.test.ts | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts diff --git a/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts new file mode 100644 index 000000000..76194cad0 --- /dev/null +++ b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts @@ -0,0 +1,241 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let telemetryFilePath: string; + +vi.mock('../../../../src/lib/consts.js', async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + TELEMETRY_FILE_PATH: () => telemetryFilePath, + }; +}); + +vi.mock('../../../../src/lib/utils.js', () => ({ + getLocalUserInfo: async () => ({}), +})); + +vi.mock('../../../../src/lib/outputs.js', () => ({ + info: () => {}, +})); + +function writeTelemetryState(state: Record) { + const dir = dirname(telemetryFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(telemetryFilePath, JSON.stringify(state, null, '\t')); +} + +function readTelemetryState() { + return JSON.parse(readFileSync(telemetryFilePath, 'utf-8')); +} + +describe('checkAndUpdateLastCommand', () => { + let testDir: string; + let counter = 0; + + beforeEach(() => { + counter++; + testDir = join(tmpdir(), `apify-cli-test-telemetry-${process.pid}-${counter}-${Date.now()}`); + telemetryFilePath = join(testDir, 'telemetry.json'); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + // Clean up temp files + const { rmSync } = require('node:fs'); + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + test('returns false on first invocation (no prior command)', async () => { + vi.setSystemTime(1000); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('stores the command and timestamp in telemetry state', async () => { + vi.setSystemTime(50_000); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + await checkAndUpdateLastCommand('apify push'); + + const state = readTelemetryState(); + expect(state.lastCommand).toBe('apify push'); + expect(state.lastCommandTimestamp).toBe(50_000); + }); + + test('returns true when the same command is repeated within the retry window', async () => { + vi.setSystemTime(100_000); + + // Seed state with a recent identical command + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 95_000, // 5 seconds ago — within the 10s window + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(true); + }); + + test('returns false when the same command is repeated outside the retry window', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 80_000, // 20 seconds ago — outside the 10s window + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('returns false when a different command is run within the retry window', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 95_000, + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify push'); + + expect(result).toBe(false); + }); + + test('updates state after checking so the next call sees the new command', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 90_000, + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + await checkAndUpdateLastCommand('apify push'); + + const state = readTelemetryState(); + expect(state.lastCommand).toBe('apify push'); + expect(state.lastCommandTimestamp).toBe(100_000); + }); + + test('returns false when lastCommandTimestamp is missing', async () => { + vi.setSystemTime(100_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + // no lastCommandTimestamp + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('returns false when telemetry state file is corrupted', async () => { + // Write invalid JSON + const dir = dirname(telemetryFilePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(telemetryFilePath, '{{{invalid json'); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); + + test('returns true at exactly the retry window boundary', async () => { + // Command was run exactly 9999ms ago (just inside the 10_000ms window) + vi.setSystemTime(109_999); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 100_000, + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(true); + }); + + test('returns false at exactly the retry window boundary (equal to window)', async () => { + // Command was run exactly 10_000ms ago (at the boundary, not strictly less than) + vi.setSystemTime(110_000); + + writeTelemetryState({ + version: 1, + enabled: true, + anonymousId: 'CLI:test', + lastCommand: 'apify run', + lastCommandTimestamp: 100_000, + }); + + const { checkAndUpdateLastCommand } = await import( + '../../../../src/lib/hooks/telemetry/useTelemetryState.js' + ); + + const result = await checkAndUpdateLastCommand('apify run'); + + expect(result).toBe(false); + }); +}); From 8826f929c7509b884be3df66e2bcc6a3a25a1bf8 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Thu, 9 Apr 2026 12:53:10 +0200 Subject: [PATCH 10/11] fix: resolve lint errors in checkAndUpdateLastCommand test Co-Authored-By: Claude Opus 4.6 (1M context) --- test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts index 76194cad0..1995d97d5 100644 --- a/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts +++ b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; let telemetryFilePath: string; @@ -18,7 +18,7 @@ vi.mock('../../../../src/lib/utils.js', () => ({ })); vi.mock('../../../../src/lib/outputs.js', () => ({ - info: () => {}, + info: () => { /* noop */ }, })); function writeTelemetryState(state: Record) { @@ -47,7 +47,6 @@ describe('checkAndUpdateLastCommand', () => { afterEach(() => { vi.useRealTimers(); // Clean up temp files - const { rmSync } = require('node:fs'); if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } From 69df104397cf2d7a45db4306f447ec7cbd3113af Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Thu, 9 Apr 2026 12:58:40 +0200 Subject: [PATCH 11/11] fix: format checkAndUpdateLastCommand test to pass CI Co-Authored-By: Claude Opus 4.6 (1M context) --- .../checkAndUpdateLastCommand.test.ts | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts index 1995d97d5..845c7a6c5 100644 --- a/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts +++ b/test/lib/hooks/telemetry/checkAndUpdateLastCommand.test.ts @@ -18,7 +18,9 @@ vi.mock('../../../../src/lib/utils.js', () => ({ })); vi.mock('../../../../src/lib/outputs.js', () => ({ - info: () => { /* noop */ }, + info: () => { + /* noop */ + }, })); function writeTelemetryState(state: Record) { @@ -55,9 +57,7 @@ describe('checkAndUpdateLastCommand', () => { test('returns false on first invocation (no prior command)', async () => { vi.setSystemTime(1000); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run'); @@ -67,9 +67,7 @@ describe('checkAndUpdateLastCommand', () => { test('stores the command and timestamp in telemetry state', async () => { vi.setSystemTime(50_000); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); await checkAndUpdateLastCommand('apify push'); @@ -90,9 +88,7 @@ describe('checkAndUpdateLastCommand', () => { lastCommandTimestamp: 95_000, // 5 seconds ago — within the 10s window }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run'); @@ -110,9 +106,7 @@ describe('checkAndUpdateLastCommand', () => { lastCommandTimestamp: 80_000, // 20 seconds ago — outside the 10s window }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run'); @@ -130,9 +124,7 @@ describe('checkAndUpdateLastCommand', () => { lastCommandTimestamp: 95_000, }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify push'); @@ -150,9 +142,7 @@ describe('checkAndUpdateLastCommand', () => { lastCommandTimestamp: 90_000, }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); await checkAndUpdateLastCommand('apify push'); @@ -172,9 +162,7 @@ describe('checkAndUpdateLastCommand', () => { // no lastCommandTimestamp }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run'); @@ -187,9 +175,7 @@ describe('checkAndUpdateLastCommand', () => { mkdirSync(dir, { recursive: true }); writeFileSync(telemetryFilePath, '{{{invalid json'); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run'); @@ -208,9 +194,7 @@ describe('checkAndUpdateLastCommand', () => { lastCommandTimestamp: 100_000, }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run'); @@ -229,9 +213,7 @@ describe('checkAndUpdateLastCommand', () => { lastCommandTimestamp: 100_000, }); - const { checkAndUpdateLastCommand } = await import( - '../../../../src/lib/hooks/telemetry/useTelemetryState.js' - ); + const { checkAndUpdateLastCommand } = await import('../../../../src/lib/hooks/telemetry/useTelemetryState.js'); const result = await checkAndUpdateLastCommand('apify run');