diff --git a/packages/cli-doctor/README.md b/packages/cli-doctor/README.md index d5367c447..d0e25e356 100644 --- a/packages/cli-doctor/README.md +++ b/packages/cli-doctor/README.md @@ -1,6 +1,6 @@ # @percy/cli-doctor -> Percy CLI sub-command that diagnoses network readiness for running Percy builds. +> Percy CLI sub-command that diagnoses network, authentication, configuration, and CI readiness for running Percy builds. --- @@ -30,10 +30,14 @@ percy doctor [options] Options: --proxy-server Proxy server to test alongside direct connectivity e.g. http://proxy.corp.example.com:8080 - --url Extra URL(s) to probe (comma-separated) - --timeout Per-request timeout in milliseconds (default: 10000) - --fix Automatically apply suggested Percy config fixes - -v, --verbose Log everything + --url URL to open in Chrome for network activity analysis + (default: https://percy.io) + --timeout Per-request timeout in milliseconds + (default: 10000, max: 300000) + --quick Run only connectivity, SSL, and token auth checks + (~4 seconds instead of a full diagnostic run) + --output-json Write the full diagnostic report to a JSON file + -v, --verbose Show detailed debug output -h, --help Show help ``` @@ -41,20 +45,64 @@ Options: ## What it checks -### 1 · SSL / TLS +### 1 · Configuration Validation -| Scenario | Outcome | +Detects and validates Percy configuration files (`.percy.yml`, `.percy.yaml`, `percy.config.js`, etc.) via cosmiconfig: + +- Reports file location and format +- Warns on missing or outdated `version` field (recommends version 2) +- Detects project-type/config mismatches (e.g., automate-only keys like `fullPage` used with a web token) + +| Category | Meaning | |---|---| -| `NODE_TLS_REJECT_UNAUTHORIZED=0` is set | **Warning** – SSL verification is disabled globally | -| SSL certificate error connecting to percy.io | **Fail** – likely a MITM proxy/VPN; suggests remediation | -| SSL handshake succeeds | **Pass** | +| `config_not_found` | No Percy config file detected | +| `config_found` | Config file located and loaded | +| `config_parse_error` | Config file has YAML/JSON syntax errors | +| `config_version_invalid` | `version` field is missing or non-numeric | +| `config_version_outdated` | Config uses an outdated version (< 2) | +| `config_key_automate_only` | Config contains Automate-only keys but token is not Automate | +| `config_key_web_only` | Config contains Web-only keys but token is not a Web token | + +### 2 · CI Environment Detection -When a certificate error is detected, the command: -* Prints actionable suggestions (contact network admin, add proxy cert to trust store) -* With `--fix`: patches the nearest `.percy.yml` to add `ssl: { rejectUnauthorized: false }` -* When running interactively in a TTY: offers an inline yes/no prompt +Detects your CI provider and validates CI-related settings: -### 2 · Network Connectivity +- Identifies 10+ CI systems (GitHub Actions, GitLab CI, Jenkins, CircleCI, Travis, etc.) +- Validates git availability for commit/branch detection +- Checks parallel build configuration (`PERCY_PARALLEL_TOTAL` + `PERCY_PARALLEL_NONCE`) + +| Category | Meaning | +|---|---| +| `ci_not_detected` | Not running in a CI environment | +| `ci_detected` | CI system identified | +| `ci_commit_missing` | Commit SHA could not be detected | +| `ci_commit_found` | Commit SHA available | +| `ci_branch_missing` | Branch name could not be detected | +| `ci_parallel_nonce_missing` | `PERCY_PARALLEL_TOTAL` set without `PERCY_PARALLEL_NONCE` | +| `ci_parallel_config_valid` | Both parallel env vars are set correctly | +| `ci_git_available` | Git repository detected | +| `ci_git_check_skipped` | Git check suppressed via `PERCY_SKIP_GIT_CHECK=true` | +| `ci_git_unavailable` | Git not installed or not in a git repository | + +### 3 · Environment Variable Audit + +Inventories all Percy-specific environment variables: + +- Lists all set `PERCY_*` vars (names only — values are never exposed) +- Validates `PERCY_PARALLEL_TOTAL` is a positive integer +- Flags manual overrides (`PERCY_COMMIT`, `PERCY_BRANCH`, `PERCY_PULL_REQUEST`) +- Warns when `NODE_TLS_REJECT_UNAUTHORIZED=0` disables SSL validation + +| Category | Meaning | +|---|---| +| `env_system_info` | OS, Node version, CPU, and RAM summary | +| `env_no_percy_vars` | No `PERCY_*` environment variables detected | +| `env_vars_listed` | Lists names of all set `PERCY_*` variables | +| `env_parallel_total_invalid` | `PERCY_PARALLEL_TOTAL` is not a valid positive integer | +| `env_manual_overrides` | Manual override vars active (e.g. `PERCY_COMMIT`, `PERCY_BRANCH`) | +| `env_tls_disabled` | `NODE_TLS_REJECT_UNAUTHORIZED=0` — SSL validation globally disabled | + +### 4 · Network Connectivity Probes each required Percy / BrowserStack domain: @@ -70,7 +118,18 @@ Failure modes are classified as: * **ETIMEDOUT / ECONNRESET** → Firewall dropping packets; list CIDRs to whitelist * **via proxy only** → Proxy required, suggests setting `HTTPS_PROXY` -### 3 · Proxy Detection +### 5 · SSL / TLS + +| Scenario | Outcome | +|---|---| +| `NODE_TLS_REJECT_UNAUTHORIZED=0` is set | **Warning** – SSL verification is disabled globally | +| SSL certificate error connecting to percy.io | **Fail** – likely a MITM proxy/VPN; suggests remediation | +| SSL handshake succeeds | **Pass** | + +When a certificate error is detected, the command prints actionable suggestions +(contact network admin, add proxy cert to trust store, set `NODE_EXTRA_CA_CERTS`). + +### 6 · Proxy Detection Detects proxy configuration from (in priority order): @@ -83,7 +142,7 @@ Detects proxy configuration from (in priority order): Each discovered proxy is validated by attempting connections to percy.io and browserstack.com through it. -### 4 · PAC / WPAD Auto-Proxy Configuration +### 7 · PAC / WPAD Auto-Proxy Configuration Detects PAC file URLs from: @@ -103,12 +162,72 @@ using shims for all standard PAC helper functions. The result of If a PAC file routes percy.io through a proxy the command surfaces the exact `HTTPS_PROXY=…` export statement to add to your CI environment. +### 8 · Token Authentication + +Validates the `PERCY_TOKEN` environment variable: + +- **Presence**: Checks the token is set +- **Format**: Detects project type from token prefix (`web_`, `auto_`, `app_`, `ss_`, `vmw_`, `res_`) and suggests the correct CLI command +- **Authentication**: Makes a live API call to `percy.io/api/v1/tokens` to verify the token is valid (uses proxy if one was discovered in earlier checks) + +Token values are **never** included in output — only the project type and pass/fail status. + +| Category | Meaning | +|---|---| +| `token_missing` | `PERCY_TOKEN` is not set or is blank | +| `token_type_info` | Token prefix decoded — project type and suggested CLI command | +| `token_auth_pass` | Token authenticated successfully; role shown | +| `token_auth_fail` | Token rejected (HTTP 401 or 403) | +| `token_auth_unexpected_status` | Percy API returned an unexpected HTTP status | +| `token_auth_network_error` | Could not reach Percy API to validate token | +| `check_skipped` | Token validation skipped because percy.io is unreachable | + +### 9 · Browser Network (Chrome CDP) + +Launches headless Chrome to test end-to-end network connectivity through the browser process, including proxy and PAC resolution as Chrome would see it. + +--- + +## Quick Mode + +Use `--quick` to run only the essential checks (connectivity, SSL, and token auth) in ~4 seconds: + +```sh +npx percy doctor --quick +``` + +This is useful for fast triage in CI pipelines or when you just want to verify your token and network are working. + +--- + +## Auto-Doctor on Build Failure + +Set `PERCY_AUTO_DOCTOR=true` to automatically run diagnostics when a Percy build fails: + +```sh +export PERCY_AUTO_DOCTOR=true +npx percy exec -- your-test-command +``` + +When enabled, a build failure triggers a diagnostic run and prints actionable findings inline. This is opt-in and has no effect on successful builds. + --- ## Example output ``` - Percy Doctor — network readiness check + Percy Doctor — diagnostic check + +── Configuration + ℹ Configuration file detected: /project/.percy.yml + ✔ Config version: 2 (current) + +── CI Environment + ℹ CI system detected: GitHub Actions + ✔ Git is available for commit detection. + +── Environment Variables + ℹ Percy environment variables set: PERCY_TOKEN, PERCY_PARALLEL_TOTAL ── SSL / TLS ✔ SSL handshake with percy.io succeeded (47ms). @@ -124,30 +243,16 @@ If a PAC file routes percy.io through a proxy the command surfaces the exact ── PAC / Auto-Proxy Configuration ℹ No PAC (Proxy Auto-Configuration) file detected. -✔ All 6 checks passed -``` - ---- +── Token Authentication + ℹ Token detected (project type: web). Use `percy exec` to run snapshots. + ✔ Token authentication successful. -## Configuration fix (`--fix`) +── Browser Network + ✔ Chrome loaded percy.io successfully. -When the doctor detects an SSL certificate error it can automatically patch the -nearest Percy configuration file (`.percy.yml` / `.percy.yaml`): - -```sh -percy doctor --fix -``` - -This appends the following snippet to your config: - -```yaml -ssl: - rejectUnauthorized: false + ✔ 8 passed · 0 warnings · 0 failures (4.2s) ``` -> **Note**: disabling SSL verification is a security trade-off. Use it only -> when your network proxy performs SSL inspection and you trust the proxy's CA. - --- ## License diff --git a/packages/cli-doctor/package.json b/packages/cli-doctor/package.json index 2f433d2c6..4154bfd52 100644 --- a/packages/cli-doctor/package.json +++ b/packages/cli-doctor/package.json @@ -20,14 +20,7 @@ "main": "./dist/index.js", "type": "module", "exports": { - ".": "./dist/index.js", - "./src/utils/http.js": "./src/utils/http.js", - "./src/utils/reporter.js": "./src/utils/reporter.js", - "./src/utils/helpers.js": "./src/utils/helpers.js", - "./src/checks/connectivity.js": "./src/checks/connectivity.js", - "./src/checks/proxy.js": "./src/checks/proxy.js", - "./src/checks/pac.js": "./src/checks/pac.js", - "./src/checks/browser.js": "./src/checks/browser.js" + ".": "./dist/index.js" }, "scripts": { "build": "node ../../scripts/build", @@ -43,8 +36,12 @@ }, "dependencies": { "@percy/cli-command": "1.31.10-beta.2", + "@percy/client": "1.31.10-beta.2", + "@percy/config": "1.31.10-beta.1", "@percy/core": "1.31.10-beta.2", + "@percy/env": "1.31.10-beta.1", "@percy/logger": "1.31.10-beta.2", + "@percy/monitoring": "1.31.10-beta.2", "minimatch": "^9.0.0", "ws": "^8.17.1" } diff --git a/packages/cli-doctor/src/checks/auth.js b/packages/cli-doctor/src/checks/auth.js new file mode 100644 index 000000000..29ab2fa36 --- /dev/null +++ b/packages/cli-doctor/src/checks/auth.js @@ -0,0 +1,162 @@ +import { httpProber } from '../utils/http.js'; + +// Strip raw token value from error messages to prevent credential leakage. +// Uses split/join instead of replaceAll for Node.js 14 compatibility. +function sanitizeError(msg) { + /* istanbul ignore next */ + if (!msg) return msg; + let sanitized = msg.replace(/Token token=[^\s,;)']*/gi, 'Token token=***'); + /* istanbul ignore next */ + const token = process.env.PERCY_TOKEN?.trim(); + /* istanbul ignore next */ + if (token) sanitized = sanitized.split(token).join('***'); + return sanitized; +} + +/** + * Validate PERCY_TOKEN presence and authenticate against the Percy API. + * Uses GET /api/v1/token to obtain token type and role directly from the API — + * no client-side prefix splitting required. + * + * @param {object} [options] + * @param {string} [options.proxyUrl] - Proxy for API call (from ctx.bestProxy) + * @param {number} [options.timeout] - Request timeout ms + * @param {string} [options.apiBaseUrl] - Base URL for token API (for testing) + * @returns {Promise} + */ +export async function checkAuth(options = {}) { + const { proxyUrl, timeout = 10000, apiBaseUrl = 'https://percy.io' } = options; + const findings = []; + const token = process.env.PERCY_TOKEN?.trim(); + + // 1. Presence check + if (!token) { + findings.push({ + category: 'token_missing', + status: 'fail', + message: 'PERCY_TOKEN is not set.', + suggestions: [ + 'Set PERCY_TOKEN in your environment: export PERCY_TOKEN=', + 'Get your token from: https://percy.io → Project Settings → API Token', + 'In CI, add PERCY_TOKEN as a secret environment variable.' + ] + }); + return findings; + } + + // 2. Authentication test — GET /api/v1/token returns type + role on 200, + // 401 for invalid/expired token, 403 for forbidden. + try { + const result = await httpProber.probeUrl( + `${apiBaseUrl}/api/v1/tokens/details`, + { + proxyUrl, + timeout, + method: 'GET', + headers: { Authorization: `Token token=${token}` } + } + ); + + const httpStatus = result.status; + + if (httpStatus === 200) { + // Parse project-token-type and role from response body + let projectTokenType = 'unknown'; + let role = 'unknown'; + try { + /* istanbul ignore next */ + const body = typeof result.body === 'string' + ? JSON.parse(result.body) + /* istanbul ignore next */ + : result.body; + /* istanbul ignore next */ + projectTokenType = body?.data?.attributes?.['project-token-type'] ?? 'unknown'; + /* istanbul ignore next */ + role = body?.data?.attributes?.role ?? 'unknown'; + } catch { + // Malformed JSON — leave defaults, still a pass + } + + const cmd = projectTokenType === 'app' ? 'percy app:exec' : 'percy exec'; + + // DR-002: token type info (NEVER emit the token value) + findings.push({ + category: 'token_type_info', + status: 'info', + message: `Token type: ${projectTokenType}. Use \`${cmd}\` to run snapshots.`, + metadata: { projectTokenType, role } + }); + + // DR-003: authentication pass with role and actionable hints + const suggestions = []; + if (role === 'read_only') { + suggestions.push('This token cannot create Percy builds. Use a master or write_only token in CI.'); + } else if (role === 'write_only') { + suggestions.push('This token can create builds but cannot read results via the API.'); + } + + findings.push({ + category: 'token_auth_pass', + status: 'pass', + message: `Token authentication successful — role: ${role}.`, + ...(suggestions.length > 0 && { suggestions }) + }); + } else if (httpStatus === 401) { + findings.push({ + category: 'token_auth_fail', + status: 'fail', + message: 'Token authentication failed (HTTP 401 Unauthorized).', + suggestions: [ + 'Your token may be expired, revoked, or incorrectly copied.', + 'Get a new token from: https://percy.io → Project Settings → API Token', + 'Ensure the full token is set (no truncation from copy-paste).' + ] + }); + } else if (httpStatus === 403) { + findings.push({ + category: 'token_auth_fail', + status: 'fail', + message: 'Token access denied (HTTP 403 Forbidden).', + suggestions: [ + 'The token may exist but is not authorized for this operation.', + 'Verify you are using the correct token for your project.', + 'Get your token from: https://percy.io → Project Settings → API Token' + ] + }); + } else if (!httpStatus || httpStatus === 0) { + // Network error — couldn't reach the API at all + findings.push({ + category: 'token_auth_network_error', + status: 'warn', + message: `Token auth check could not reach Percy API: ${sanitizeError(result.error)}`, + suggestions: [ + 'This may be a network issue rather than a token issue.', + 'See the Connectivity and Proxy sections for network diagnostics.' + ] + }); + } else { + // Unexpected status code + findings.push({ + category: 'token_auth_unexpected_status', + status: 'warn', + message: `Token auth returned unexpected HTTP ${httpStatus}.`, + suggestions: [ + 'This may indicate a Percy API issue. Try again later.', + 'If persistent, contact Percy support with this diagnostic output.' + ] + }); + } + } catch (err) { + findings.push({ + category: 'token_auth_network_error', + status: 'warn', + message: `Token auth check could not reach Percy API: ${sanitizeError(err.message)}`, + suggestions: [ + 'This may be a network issue rather than a token issue.', + 'See the Connectivity and Proxy sections for network diagnostics.' + ] + }); + } + + return findings; +} diff --git a/packages/cli-doctor/src/checks/config.js b/packages/cli-doctor/src/checks/config.js new file mode 100644 index 000000000..91a69b0ab --- /dev/null +++ b/packages/cli-doctor/src/checks/config.js @@ -0,0 +1,121 @@ +import { search as defaultSearch } from '@percy/config'; +import { tokenType } from '@percy/client'; + +/** + * Validate Percy configuration file presence, format, and content. + * Plain async function — matches monorepo functional style. + * + * @param {object} [options] + * @param {function} [options.searchFn] - Config search function (for testing) + * @returns {Promise} + */ +export async function checkConfig(options = {}) { + const { searchFn = defaultSearch } = options; + const findings = []; + + // 1. Detect config files using cosmiconfig (same as @percy/config) + let result; + try { + result = searchFn(); + } catch (err) { + findings.push({ + category: 'config_parse_error', + status: 'fail', + message: `Config file could not be loaded: ${err.message}`, + suggestions: [ + 'Check for YAML/JSON syntax errors in your config file.', + 'Run: percy config:validate for detailed error output.' + ] + }); + return findings; + } + + if (!result?.config) { + findings.push({ + category: 'config_not_found', + status: 'info', + message: 'No Percy configuration file detected.', + suggestions: [ + 'Percy works with default settings when no config file is present.', + 'Create .percy.yml to customize snapshot widths, CSS, and other options.' + ] + }); + return findings; + } + + // 2. Config file found + findings.push({ + category: 'config_found', + status: 'pass', + message: `Configuration file found: ${result.filepath}` + }); + + // 3. Version check + const version = parseInt(result.config.version, 10); + if (Number.isNaN(version)) { + findings.push({ + category: 'config_version_invalid', + status: 'warn', + message: 'Configuration file has missing or invalid version.', + suggestions: ['Add `version: 2` to the top of your Percy config file.'] + }); + } else if (version < 2) { + findings.push({ + category: 'config_version_outdated', + status: 'warn', + message: `Configuration file uses an outdated format (version ${version}).`, + suggestions: ['Run: percy config:migrate to update to the latest format.'] + }); + } + + // 4. Check for project-type-specific config mismatches + // @percy/config silently deletes keys with onlyAutomate/onlyWeb constraints + // when the token doesn't match. Surface this to the user. + const token = process.env.PERCY_TOKEN?.trim(); + if (token && result.config) { + const prefix = token.split('_')[0]; + /* istanbul ignore next */ + const projectType = tokenType(prefix); + const isAutomate = prefix === 'auto'; + + // Keys that only work with automate tokens + const automateOnlyKeys = ['fullPage', 'freezeAnimation', 'freezeAnimatedImage', + 'freezeAnimatedImageOptions', 'ignoreRegions', 'considerRegions']; + // Keys that only work with web tokens (not automate, not app) + const webOnlyKeys = ['waitForTimeout', 'waitForSelector']; + + const snapshotConfig = result.config.snapshot || {}; + + if (!isAutomate) { + const mismatched = automateOnlyKeys.filter(k => snapshotConfig[k] !== undefined); + if (mismatched.length > 0) { + findings.push({ + category: 'config_key_automate_only', + status: 'warn', + message: `Config keys only supported for Automate projects: ${mismatched.join(', ')}. Your token is for "${projectType}" project type.`, + suggestions: [ + 'These config keys will be silently ignored for your project type.', + 'Remove them from your config or use an Automate project token.' + ] + }); + } + } + + if (projectType !== 'web') { + const mismatched = webOnlyKeys.filter(k => snapshotConfig[k] !== undefined); + if (mismatched.length > 0) { + findings.push({ + category: 'config_key_web_only', + status: 'warn', + message: `Config keys only supported for Web projects: ${mismatched.join(', ')}. Your token is for "${projectType}" project type.`, + suggestions: [ + 'These config keys will be silently ignored for your project type.', + 'Remove them from your config or use a Web project token.' + ] + }); + } + } + } + + return findings; +} diff --git a/packages/cli-doctor/src/checks/env-audit.js b/packages/cli-doctor/src/checks/env-audit.js new file mode 100644 index 000000000..a8b70bb68 --- /dev/null +++ b/packages/cli-doctor/src/checks/env-audit.js @@ -0,0 +1,250 @@ +import PercyEnv from '@percy/env'; +import cp from 'child_process'; +import Monitoring from '@percy/monitoring'; + +/** + * Combined check: Percy environment variable audit + CI environment detection. + * + * - Percy env vars are captured via @percy/monitoring (getPercyEnv / logSystemInfo). + * - System info (OS, CPU, memory, disk) is captured via monitoring.logSystemInfo() + * and surfaced as a env_system_info finding. + * - CI detection and validation (commit SHA, branch, parallel config, git) is + * merged in from the former ci.js — call ONE function for the whole analysis. + * + * SECURITY: Environment variable VALUES are never emitted in findings. + * Only variable NAMES and validation status are reported. + * + * Dependency injection for testing: + * - monitoringInstance substitute a mock Monitoring instance + * - percyEnv substitute a mock PercyEnv-like object + * (must expose .ci, .commit, .branch getters) + * + * @param {object} [options] + * @param {object} [options.monitoringInstance] - Monitoring instance (for testing) + * @param {object} [options.percyEnv] - PercyEnv-like object (for testing) + * @returns {Promise} + */ +export async function checkEnvAndCI({ monitoringInstance, percyEnv: percyEnvArg } = {}) { + const findings = []; + + // ── PART A: System info + Percy env vars via @percy/monitoring ──────────── + + const monitoring = monitoringInstance ?? new Monitoring(); + + // logSystemInfo now returns details AND emits debug logs. + let systemInfo = null; + try { + systemInfo = await monitoring.logSystemInfo(); + } catch { + // Non-fatal: system info is best-effort. + } + + // Use percyEnvs from systemInfo if available; fallback to reading process.env + // directly. This ensures env vars set at runtime (e.g. in tests via withEnv) + // are always picked up without requiring monitoring to reflect them. + function getPercyEnvFromProcess() { + return Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('PERCY_') && !k.toLowerCase().includes('token')) + ); + } + const percyEnvObj = systemInfo?.percyEnvs ?? getPercyEnvFromProcess(); + const nonTokenPercyVarNames = Object.keys(percyEnvObj); + + // PERCY_TOKEN is excluded from getPercyEnv() by design (token filter). + // Detect its presence separately — NAME only, never value. + const hasToken = (process.env.PERCY_TOKEN ?? '').trim().length > 0; + const allSetVarNames = hasToken + ? ['PERCY_TOKEN', ...nonTokenPercyVarNames] + : nonTokenPercyVarNames; + + // env_system_info: System info (sourced from monitoring.logSystemInfo return value) + /* istanbul ignore if */ + if (systemInfo) { + const memGb = systemInfo.memory?.totalGb != null + ? systemInfo.memory.totalGb.toFixed(1) + : 'N/A'; + const cores = systemInfo.cpu?.cores ?? 'N/A'; + const platform = systemInfo.os?.platform ?? process.platform; + const release = systemInfo.os?.release ?? 'N/A'; + findings.push({ + category: 'env_system_info', + status: 'info', + message: `System: ${platform} ${release}, Node ${process.version}, ${cores} CPU core(s), ${memGb}GB RAM`, + metadata: { + os: systemInfo.os, + cpu: systemInfo.cpu, + disk: systemInfo.disk, + memory: systemInfo.memory, + containerInfo: systemInfo.containerInfo + } + }); + } + + // env_no_percy_vars/301: Percy env var listing + if (allSetVarNames.length === 0) { + findings.push({ + category: 'env_no_percy_vars', + status: 'info', + message: 'No Percy-specific environment variables detected (only PERCY_TOKEN is required).' + }); + } else { + findings.push({ + category: 'env_vars_listed', + status: 'info', + message: `Percy environment variables set: ${allSetVarNames.join(', ')}` + }); + } + + // env_parallel_total_invalid: Validate PERCY_PARALLEL_TOTAL format + if (process.env.PERCY_PARALLEL_TOTAL) { + const val = Number(process.env.PERCY_PARALLEL_TOTAL); + if (!Number.isInteger(val) || val <= 0) { + findings.push({ + category: 'env_parallel_total_invalid', + status: 'fail', + message: 'PERCY_PARALLEL_TOTAL is not a valid positive integer.', + suggestions: [ + 'Set PERCY_PARALLEL_TOTAL to a positive integer (e.g., 4).', + 'This controls how many parallel Percy build shards to expect.' + ] + }); + } + } + + // env_manual_overrides: Warn about manual overrides + const overrideVars = [ + 'PERCY_COMMIT', 'PERCY_BRANCH', 'PERCY_PULL_REQUEST', + 'PERCY_TARGET_COMMIT', 'PERCY_TARGET_BRANCH' + ]; + const activeOverrides = overrideVars.filter(k => process.env[k]); + if (activeOverrides.length > 0) { + findings.push({ + category: 'env_manual_overrides', + status: 'info', + message: `Manual overrides active: ${activeOverrides.join(', ')} — these override CI-detected values.` + }); + } + + // env_tls_disabled: Detect NODE_TLS_REJECT_UNAUTHORIZED=0 + if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') { + findings.push({ + category: 'env_tls_disabled', + status: 'warn', + message: 'NODE_TLS_REJECT_UNAUTHORIZED=0 — SSL certificate validation is globally disabled.', + suggestions: [ + 'This disables ALL SSL certificate validation for this process.', + 'Use NODE_EXTRA_CA_CERTS=/path/to/ca.crt for a safer permanent fix.', + 'Only use NODE_TLS_REJECT_UNAUTHORIZED=0 for temporary debugging.' + ] + }); + } + + // ── PART B: CI environment check ───────────────────────────────────────── + // Uses injected percyEnv for testability, or creates a fresh PercyEnv that + // reads from process.env. Injection prevents GITHUB_EVENT_PATH caching + // issues when running tests inside actual CI environments. + + const env = percyEnvArg ?? new PercyEnv(); + + // ci_not_detected: Not in CI + if (!env.ci) { + findings.push({ + category: 'ci_not_detected', + status: 'info', + message: 'Not running in a CI environment (local machine).', + suggestions: ['Percy doctor is most useful when run in your CI pipeline.'] + }); + return findings; + } + + // ci_detected: CI system detected + findings.push({ + category: 'ci_detected', + status: 'pass', + message: `CI system detected: ${env.ci}` + }); + + // ci_commit_missing/203: Commit SHA + const commit = env.commit; + if (!commit) { + findings.push({ + category: 'ci_commit_missing', + status: 'warn', + message: 'Could not detect commit SHA from CI environment.', + suggestions: [ + 'Percy needs a commit SHA for baseline comparison.', + 'Set PERCY_COMMIT= as a fallback.' + ] + }); + } else { + findings.push({ + category: 'ci_commit_found', + status: 'pass', + message: `Commit SHA: ${commit.slice(0, 12)}...` + }); + } + + // ci_branch_missing: Branch + const branch = env.branch; + if (!branch) { + findings.push({ + category: 'ci_branch_missing', + status: 'warn', + message: 'Could not detect branch name from CI environment.', + suggestions: ['Set PERCY_BRANCH= as a fallback.'] + }); + } + + // ci_parallel_nonce_missing/206: Parallel config + if (process.env.PERCY_PARALLEL_TOTAL) { + if (!process.env.PERCY_PARALLEL_NONCE) { + findings.push({ + category: 'ci_parallel_nonce_missing', + status: 'warn', + message: 'PERCY_PARALLEL_TOTAL is set but PERCY_PARALLEL_NONCE is missing.', + suggestions: [ + 'Both PERCY_PARALLEL_TOTAL and PERCY_PARALLEL_NONCE must be set for parallel builds.', + 'The nonce should be unique per build run (e.g., CI build number).' + ] + }); + } else { + findings.push({ + category: 'ci_parallel_config_valid', + status: 'pass', + message: 'Parallel build configuration detected (PERCY_PARALLEL_TOTAL and PERCY_PARALLEL_NONCE are set).' + }); + } + } + + // ci_git_available/208/209: Git availability + try { + cp.execSync('git rev-parse --is-inside-work-tree', { timeout: 5000, stdio: 'pipe' }); + findings.push({ + category: 'ci_git_available', + status: 'pass', + message: 'Git repository detected.' + }); + } catch { + if (process.env.PERCY_SKIP_GIT_CHECK === 'true') { + findings.push({ + category: 'ci_git_check_skipped', + status: 'info', + message: 'PERCY_SKIP_GIT_CHECK=true — git validation skipped.' + }); + } else { + findings.push({ + category: 'ci_git_unavailable', + status: 'warn', + message: 'Git is not available or not in a git repository.', + suggestions: [ + 'Percy uses git to detect commit and branch information.', + 'Install git or set PERCY_COMMIT and PERCY_BRANCH manually.', + 'Or set PERCY_SKIP_GIT_CHECK=true to suppress this warning.' + ] + }); + } + } + + return findings; +} diff --git a/packages/cli-doctor/src/doctor.js b/packages/cli-doctor/src/doctor.js index 4621979fa..bc2df57ea 100644 --- a/packages/cli-doctor/src/doctor.js +++ b/packages/cli-doctor/src/doctor.js @@ -8,9 +8,12 @@ import { runConnectivityAndSSL, runProxyCheck, runPACCheck, - runBrowserCheck + runBrowserCheck, + runAuthCheck, + runConfigCheck, + runEnvAuditCheck } from './utils/helpers.js'; -import { checkLine, print } from './utils/reporter.js'; +import { checkLine, summaryBanner, print } from './utils/reporter.js'; import { getPackageJSON } from '@percy/cli-command/utils'; const pkg = getPackageJSON(import.meta.url); @@ -23,6 +26,7 @@ export const doctor = command( examples: [ '$0', + '$0 --quick', '$0 --proxy-server http://proxy.corp.example.com:8080', '$0 --url https://my-staging.example.com', '$0 --output-json ./percy-doctor.json' @@ -54,10 +58,18 @@ export const doctor = command( 'Write the full diagnostic report to a JSON file', type: 'string', attribute: 'outputJson' + }, + { + name: 'quick', + description: + 'Run only connectivity, SSL, and token checks (~4s)', + type: 'boolean', + default: false } ] }, async ({ flags, log, exit }) => { + const startTime = Date.now(); const proxyUrl = flags.proxyServer || process.env.HTTPS_PROXY || @@ -66,16 +78,31 @@ export const doctor = command( process.env.http_proxy || null; - const timeout = parseInt(flags.timeout ?? '10000', 10); - if (isNaN(timeout) || timeout <= 0) { - log.error('--timeout must be a positive integer (milliseconds)'); + const MAX_TIMEOUT = 300000; // 5 minutes + const rawTimeout = Number(flags.timeout ?? '10000'); + const timeout = Number.isInteger(rawTimeout) ? rawTimeout : NaN; + if (isNaN(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT) { + log.error(`--timeout must be a positive integer up to ${MAX_TIMEOUT}ms (5 minutes)`); return exit(1, '--timeout must be a positive integer', false); } const targetUrl = flags.url?.trim() || 'https://percy.io'; const jsonOutputPath = flags.outputJson ?? null; + const mode = flags.quick ? 'quick' : 'default'; + + // Inter-check context — plain data object + const ctx = { + proxyUrl, + timeout, + targetUrl, + discoveredProxies: [], + connectivityOk: null, + pacResolvedProxy: null + }; const report = { + version: '1.0.0', timestamp: new Date().toISOString(), + mode, environment: { percyCLIVersion: pkg.version, platform: process.platform, @@ -87,30 +114,88 @@ export const doctor = command( checks: {} }; - print(log, '\n Percy Doctor — network readiness check\n'); + print(log, `\n Percy Doctor — diagnostic check${mode === 'quick' ? ' (quick mode)' : ''}\n`); if (proxyUrl) { print(log, checkLine('info', `Proxy in use: ${redactProxyUrl(proxyUrl)}`)); print(log, ''); } + // NOTE: Phase 1-4 orchestration is mirrored in runDiagnostics() (helpers.js). + // If you change phase ordering or add checks here, update runDiagnostics() too. + // Phase 1: Independent checks (parallel with allSettled) — skip in quick mode + // CI is merged into runEnvAuditCheck (see env-audit.js + helpers.js). + if (mode !== 'quick') { + const phase1Results = await Promise.allSettled([ + runConfigCheck(), + runEnvAuditCheck() + ]); + report.checks.config = phase1Results[0].status === 'fulfilled' + ? phase1Results[0].value.config + : { status: 'fail', findings: [{ status: 'fail', message: phase1Results[0].reason?.message }] }; + report.checks.envAudit = phase1Results[1].status === 'fulfilled' + ? phase1Results[1].value.envAudit + : { status: 'fail', findings: [{ status: 'fail', message: phase1Results[1].reason?.message }] }; + } + + // Phase 2: Network checks (sequential — order matters for output readability) const { connectivity, ssl } = await runConnectivityAndSSL(proxyUrl, timeout); report.checks.connectivity = connectivity; report.checks.ssl = ssl; + ctx.connectivityOk = connectivity.status !== 'fail'; - const { proxy } = await runProxyCheck(timeout); - report.checks.proxy = proxy; + if (mode !== 'quick') { + const { proxy } = await runProxyCheck(timeout); + report.checks.proxy = proxy; + ctx.discoveredProxies = (proxy.findings ?? []) + .filter(f => f.proxyUrl && f.proxyValidation?.status === 'pass') + .map(f => ({ url: f.proxyUrl, source: f.source })); - const { pac } = await runPACCheck(); - report.checks.pac = pac; + const { pac } = await runPACCheck(); + report.checks.pac = pac; + ctx.pacResolvedProxy = (pac.findings ?? []).find(f => f.detectedProxyUrl)?.detectedProxyUrl ?? null; + } + + // Phase 3: Token auth (depends on connectivity + proxy discovery) + if (mode === 'quick' && !ctx.connectivityOk) { + report.checks.auth = { + status: 'skip', + findings: [{ + category: 'check_skipped', + status: 'skip', + message: 'Token validation skipped — percy.io is unreachable.', + suggestions: ['Fix connectivity issues first, then re-run percy doctor.'] + }] + }; + } else { + const { auth } = await runAuthCheck(ctx); + report.checks.auth = auth; + } - const { browser } = await runBrowserCheck(targetUrl, proxyUrl, timeout); - report.checks.browser = browser; + // Phase 4: Browser network analysis — skip in quick mode + if (mode !== 'quick') { + const bestProxy = ctx.proxyUrl || ctx.discoveredProxies[0]?.url || ctx.pacResolvedProxy || null; + const { browser } = await runBrowserCheck(targetUrl, bestProxy, timeout); + report.checks.browser = browser; + } + + const counts = { passed: 0, warned: 0, failed: 0 }; + for (const c of Object.values(report.checks)) { + if (c?.status === 'pass') counts.passed++; + else if (c?.status === 'warn') counts.warned++; + else if (c?.status === 'fail') counts.failed++; + } report.summary = { - overall: Object.values(report.checks).some(c => c?.status === 'fail') ? 'fail' - : Object.values(report.checks).some(c => c?.status === 'warn') ? 'warn' : 'pass' + overall: counts.failed > 0 ? 'fail' : counts.warned > 0 ? 'warn' : 'pass', + passed: counts.passed, + warned: counts.warned, + failed: counts.failed, + durationMs: Date.now() - startTime }; + print(log, summaryBanner(counts.passed, counts.warned, counts.failed)); + print(log, ` Completed in ${((Date.now() - startTime) / 1000).toFixed(1)}s\n`); + if (jsonOutputPath) { try { // cli-doctor runs inside the customer's own CI environment; jsonOutputPath is a diff --git a/packages/cli-doctor/src/utils/helpers.js b/packages/cli-doctor/src/utils/helpers.js index 3cd407df1..d2f16259a 100644 --- a/packages/cli-doctor/src/utils/helpers.js +++ b/packages/cli-doctor/src/utils/helpers.js @@ -80,6 +80,39 @@ export function captureProxyEnv() { return out; } +// ─── Section runner factory ────────────────────────────────────────────────── +// Generic runner that reduces boilerplate for each check section. + +/** + * Generic section runner factory. Reduces boilerplate for each check. + * @param {string} sectionName - Display name for the section header + * @param {string} checkKey - Key in the report.checks object + * @param {Function} checkFn - Async function returning findings array + * @returns {Promise} - { [checkKey]: { status, findings, durationMs } } + */ +export async function runSection(sectionName, checkKey, checkFn) { + const log = logger('percy:doctor'); + print(log, sectionHeader(sectionName)); + let findings = []; + const start = Date.now(); + try { + findings = await checkFn(); + } catch (err) { + /* istanbul ignore next */ + log.error(`${sectionName} check failed unexpectedly: ${err.message}`); + /* istanbul ignore next */ + findings = [{ status: 'fail', message: err.message }]; + } + renderFindings(findings, log); + return { + [checkKey]: { + status: sectionStatus(findings), + findings, + durationMs: Date.now() - start + } + }; +} + // ─── Section runners ────────────────────────────────────────────────────────── // Each function receives only the data it needs (proxyUrl, timeout, targetUrl). // It creates its own logger and checker internally, then returns its results. @@ -292,27 +325,85 @@ export async function runBrowserCheck(targetUrl = 'https://percy.io', proxyUrl = export async function runDiagnostics({ proxyUrl, timeout = 10000, - targetUrl = 'https://percy.io' + targetUrl = 'https://percy.io', + mode = 'default' } = {}) { + const MAX_TIMEOUT = 300000; // 5 minutes const parsedTimeout = Number(timeout); - if (!Number.isInteger(parsedTimeout) || parsedTimeout <= 0) { - throw new Error('--timeout must be a positive integer (milliseconds)'); + if (!Number.isInteger(parsedTimeout) || parsedTimeout <= 0 || parsedTimeout > MAX_TIMEOUT) { + throw new Error(`--timeout must be a positive integer up to ${MAX_TIMEOUT}ms (5 minutes)`); } const report = { checks: {} }; + // NOTE: Phase 1-4 orchestration is mirrored in doctor.js CLI handler. + // If you change phase ordering or add checks here, update doctor.js too. + // Phase 1: Independent checks — skip in quick mode + // CI is merged into runEnvAuditCheck (see env-audit.js + helpers.js). + /* istanbul ignore next */ + if (mode !== 'quick') { + const phase1Results = await Promise.allSettled([ + runConfigCheck(), + runEnvAuditCheck() + ]); + /* istanbul ignore next */ + report.checks.config = phase1Results[0].status === 'fulfilled' + ? phase1Results[0].value.config + : { status: 'fail', findings: [{ status: 'fail', message: phase1Results[0].reason?.message }] }; + /* istanbul ignore next */ + report.checks.envAudit = phase1Results[1].status === 'fulfilled' + ? phase1Results[1].value.envAudit + : { status: 'fail', findings: [{ status: 'fail', message: phase1Results[1].reason?.message }] }; + } + + // Phase 2: Network checks const { connectivity, ssl } = await runConnectivityAndSSL(proxyUrl, parsedTimeout); report.checks.connectivity = connectivity; report.checks.ssl = ssl; - const { proxy } = await runProxyCheck(parsedTimeout); - report.checks.proxy = proxy; + const connectivityOk = connectivity.status !== 'fail'; + let bestProxy = proxyUrl; + + /* istanbul ignore next */ + if (mode !== 'quick') { + const { proxy } = await runProxyCheck(parsedTimeout); + report.checks.proxy = proxy; + /* istanbul ignore next */ + const discoveredProxies = (proxy.findings ?? []) + .filter(f => f.proxyUrl && f.proxyValidation?.status === 'pass') + .map(f => ({ url: f.proxyUrl, source: f.source })); + + const { pac } = await runPACCheck(); + report.checks.pac = pac; + /* istanbul ignore next */ + const pacResolvedProxy = (pac.findings ?? []).find(f => f.detectedProxyUrl)?.detectedProxyUrl ?? null; + + bestProxy = proxyUrl || discoveredProxies[0]?.url || pacResolvedProxy || null; + } - const { pac } = await runPACCheck(); - report.checks.pac = pac; + // Phase 3: Token auth + /* istanbul ignore next */ + if (mode === 'quick' && !connectivityOk) { + report.checks.auth = { + status: 'skip', + findings: [{ + category: 'check_skipped', + status: 'skip', + message: 'Token validation skipped — percy.io is unreachable.', + suggestions: ['Fix connectivity issues first, then re-run percy doctor.'] + }] + }; + } else { + const { auth } = await runAuthCheck({ bestProxy, timeout: parsedTimeout }); + report.checks.auth = auth; + } - const { browser } = await runBrowserCheck(targetUrl, proxyUrl, parsedTimeout); - report.checks.browser = browser; + // Phase 4: Browser — skip in quick mode + /* istanbul ignore next */ + if (mode !== 'quick') { + const { browser } = await runBrowserCheck(targetUrl, bestProxy, parsedTimeout); + report.checks.browser = browser; + } const hasFail = Object.values(report.checks).some(c => c?.status === 'fail'); const hasWarn = Object.values(report.checks).some(c => c?.status === 'warn'); @@ -411,3 +502,38 @@ export function _renderBrowserResults(log, browserResult, proxyUrl) { ])); } } + +// ─── New check runners (Phase 3-6) ────────────────────────────────────────── + +/** + * Run the token auth check. + * @param {object} ctx - Doctor context with bestProxy, timeout + * @returns {Promise<{ auth: object }>} + */ +/* istanbul ignore next */ +export async function runAuthCheck(ctx = {}) { + const authModule = await import('../checks/auth.js'); + return runSection('Token Authentication', 'auth', () => + authModule.checkAuth({ proxyUrl: ctx.bestProxy, timeout: ctx.timeout }) + ); +} + +/** + * Run the config validation check. + * @returns {Promise<{ config: object }>} + */ +export async function runConfigCheck() { + const { checkConfig } = await import('../checks/config.js'); + return runSection('Percy Configuration', 'config', () => checkConfig()); +} + +/** + * Run the combined environment-variable audit + CI environment check. + * CI detection is merged into checkEnvAndCI (env-audit.js); calling + * this single runner covers both sections. + * @returns {Promise<{ envAudit: object }>} + */ +export async function runEnvAuditCheck() { + const { checkEnvAndCI } = await import('../checks/env-audit.js'); + return runSection('Environment & CI', 'envAudit', () => checkEnvAndCI()); +} diff --git a/packages/cli-doctor/src/utils/http.js b/packages/cli-doctor/src/utils/http.js index c8806126e..f73cf0e54 100644 --- a/packages/cli-doctor/src/utils/http.js +++ b/packages/cli-doctor/src/utils/http.js @@ -15,6 +15,7 @@ const DEFAULT_TIMEOUT = 10000; * @property {string} error - Error message if request failed * @property {string} errorCode - Node.js error code (e.g. CERT_HAS_EXPIRED) * @property {number} latencyMs - Round-trip time in milliseconds + * @property {string} body - Response body as UTF-8 string */ export class HttpProber { @@ -46,7 +47,8 @@ export class HttpProber { const { proxyUrl, timeout = DEFAULT_TIMEOUT, - method = 'HEAD' + method = 'HEAD', + headers = {} } = options; const start = Date.now(); @@ -54,7 +56,7 @@ export class HttpProber { try { const url = new URL(targetUrl); const proxy = proxyUrl ? new URL(proxyUrl) : null; - const result = await this.#makeRequest(url, proxy, { timeout, method }); + const result = await this.#makeRequest(url, proxy, { timeout, method, headers }); result.latencyMs = Date.now() - start; return result; } catch (err) { @@ -88,12 +90,16 @@ export class HttpProber { * • proxy + HTTPS target → CONNECT tunnel → TLS → HTTPS request * • proxy + HTTP target → plain HTTP request with absolute-URI to proxy */ - #makeRequest(url, proxy, { timeout, method }) { + #makeRequest(url, proxy, { timeout, method, headers }) { // ── common response resolver ────────────────────────────────────────────── function resolveResponse(res, resolve) { - res.resume(); // drain body const status = res.statusCode; - resolve({ ok: status >= 200 && status < 400, status, error: null, errorCode: null, responseHeaders: res.headers }); + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + resolve({ ok: status >= 200 && status < 400, status, error: null, errorCode: null, responseHeaders: res.headers, body }); + }); } // ── common timeout error ────────────────────────────────────────────────── @@ -123,6 +129,7 @@ export class HttpProber { reject(err); }; + /* istanbul ignore next */ const pass = (result) => { /* istanbul ignore next */ if (settled) return; @@ -164,20 +171,26 @@ export class HttpProber { tlsSock = tls.connect({ socket, servername: url.hostname }); tlsSock.on('error', fail); + /* istanbul ignore next */ tlsSock.on('secureConnect', () => { const req = https.request({ createConnection: () => tlsSock, hostname: url.hostname, port: targetPort, path: url.pathname + url.search, - method - }, (res) => pass({ - ok: res.statusCode >= 200 && res.statusCode < 400, - status: res.statusCode, - error: null, - errorCode: null, - responseHeaders: res.headers - })); + method, + headers + }, + /* istanbul ignore next */ + (res) => { + const status = res.statusCode; + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + pass({ ok: status >= 200 && status < 400, status, error: null, errorCode: null, responseHeaders: res.headers, body }); + }); + }); req.on('error', fail); req.end(); }); @@ -200,6 +213,7 @@ export class HttpProber { timeout, headers: { Host: url.hostname, + ...headers, ...(proxy.username ? { 'Proxy-Authorization': `Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}` } : {}) @@ -232,7 +246,8 @@ export class HttpProber { port, path: url.pathname + url.search, method, - timeout + timeout, + headers }, (res) => resolveResponse(res, resolve)); req.on('timeout', () => { req.destroy(); reject(timeoutError('Request')); }); diff --git a/packages/cli-doctor/test/auth.test.js b/packages/cli-doctor/test/auth.test.js new file mode 100644 index 000000000..713fd845e --- /dev/null +++ b/packages/cli-doctor/test/auth.test.js @@ -0,0 +1,264 @@ +/** + * Tests for packages/cli-doctor/src/checks/auth.js + * + * Mocks GET /api/v1/token — the single endpoint that returns project-token-type + * and role (master / read_only / write_only) directly from the Percy API. + * No client-side prefix splitting is performed. + */ + +import { checkAuth } from '../src/checks/auth.js'; +import { HttpProber } from '../src/utils/http.js'; +import { createHttpServer, withEnv } from './helpers.js'; + +describe('checkAuth', () => { + let mockApiUrl, closeServer; + + // Token → { status, body } map used by the mock server. + const TOKEN_RESPONSES = { + invalid_token: { status: 401, body: { errors: [{ status: 'unauthorized', detail: 'Authentication required.' }] } }, + forbidden_token: { status: 403, body: { errors: [{ status: 'forbidden', detail: 'invalid request' }] } }, + weird_token: { status: 500, body: {} }, + web_master: { + status: 200, + body: { data: { type: 'tokens', id: '1', attributes: { token: '***', role: 'master', 'project-token-type': 'web' } } } + }, + web_read_only: { + status: 200, + body: { data: { type: 'tokens', id: '2', attributes: { token: '***', role: 'read_only', 'project-token-type': 'web' } } } + }, + web_write_only: { + status: 200, + body: { data: { type: 'tokens', id: '3', attributes: { token: '***', role: 'write_only', 'project-token-type': 'web' } } } + }, + app_master: { + status: 200, + body: { data: { type: 'tokens', id: '4', attributes: { token: '***', role: 'master', 'project-token-type': 'app' } } } + }, + auto_master: { + status: 200, + body: { data: { type: 'tokens', id: '5', attributes: { token: '***', role: 'master', 'project-token-type': 'automate' } } } + }, + malformed_body_token: { status: 200, body: null } + }; + + beforeAll(async () => { + ({ url: mockApiUrl, close: closeServer } = await createHttpServer((req, res) => { + const auth = req.headers.authorization ?? ''; + if (!auth.startsWith('Token token=')) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ errors: [{ status: 'unauthorized' }] })); + return; + } + const token = auth.replace('Token token=', ''); + const spec = TOKEN_RESPONSES[token]; + if (!spec) { + // Unknown token — treat as valid project token (200 web/master default) + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: { type: 'tokens', id: '99', attributes: { token: '***', role: 'master', 'project-token-type': 'web' } } + })); + return; + } + res.writeHead(spec.status, { 'Content-Type': 'application/json' }); + res.end(spec.body ? JSON.stringify(spec.body) : ''); + })); + }); + + afterAll(() => closeServer()); + + async function check(token, opts = {}) { + return withEnv( + { PERCY_TOKEN: token }, + () => checkAuth({ timeout: 5000, apiBaseUrl: mockApiUrl, ...opts }) + ); + } + + // ── Token presence ──────────────────────────────────────────────────────── + + it('returns token_missing fail when PERCY_TOKEN is not set', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => checkAuth()); + expect(findings.length).toBe(1); + expect(findings[0].category).toBe('token_missing'); + expect(findings[0].status).toBe('fail'); + expect(findings[0].message).toContain('PERCY_TOKEN is not set'); + }); + + it('returns token_missing fail when PERCY_TOKEN is empty string', async () => { + const findings = await withEnv({ PERCY_TOKEN: '' }, () => checkAuth()); + expect(findings[0].category).toBe('token_missing'); + expect(findings[0].status).toBe('fail'); + }); + + it('returns token_missing fail when PERCY_TOKEN is whitespace only', async () => { + const findings = await withEnv({ PERCY_TOKEN: ' ' }, () => checkAuth()); + expect(findings[0].category).toBe('token_missing'); + expect(findings[0].status).toBe('fail'); + }); + + it('includes helpful suggestions for missing token', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => checkAuth()); + expect(findings[0].suggestions).toContain('Set PERCY_TOKEN in your environment: export PERCY_TOKEN='); + expect(findings[0].suggestions).toContain('In CI, add PERCY_TOKEN as a secret environment variable.'); + }); + + // ── Successful authentication (200) ────────────────────────────────────── + + it('returns token_type_info info and token_auth_pass pass for web/master token', async () => { + const findings = await check('web_master'); + const info = findings.find(f => f.category === 'token_type_info'); + const pass = findings.find(f => f.category === 'token_auth_pass'); + expect(info).toBeDefined(); + expect(info.status).toBe('info'); + expect(info.message).toContain('web'); + expect(info.metadata.projectTokenType).toBe('web'); + expect(info.metadata.role).toBe('master'); + expect(pass).toBeDefined(); + expect(pass.status).toBe('pass'); + expect(pass.message).toContain('role: master'); + }); + + it('DR-002 suggests percy exec for web token type', async () => { + const findings = await check('web_master'); + const info = findings.find(f => f.category === 'token_type_info'); + expect(info.message).toContain('percy exec'); + }); + + it('DR-002 suggests percy app:exec for app token type', async () => { + const findings = await check('app_master'); + const info = findings.find(f => f.category === 'token_type_info'); + expect(info.message).toContain('percy app:exec'); + expect(info.metadata.projectTokenType).toBe('app'); + }); + + it('DR-002 reports automate project type', async () => { + const findings = await check('auto_master'); + const info = findings.find(f => f.category === 'token_type_info'); + expect(info.metadata.projectTokenType).toBe('automate'); + expect(info.metadata.role).toBe('master'); + }); + + // ── Role-based messaging ────────────────────────────────────────────────── + + it('DR-003 pass with read_only role includes warning suggestion', async () => { + const findings = await check('web_read_only'); + const pass = findings.find(f => f.category === 'token_auth_pass'); + expect(pass).toBeDefined(); + expect(pass.status).toBe('pass'); + expect(pass.message).toContain('role: read_only'); + expect(pass.suggestions).toBeDefined(); + expect(pass.suggestions.some(s => s.includes('write_only') || s.includes('master'))).toBe(true); + }); + + it('DR-003 pass with write_only role includes informational suggestion', async () => { + const findings = await check('web_write_only'); + const pass = findings.find(f => f.category === 'token_auth_pass'); + expect(pass).toBeDefined(); + expect(pass.status).toBe('pass'); + expect(pass.message).toContain('role: write_only'); + expect(pass.suggestions).toBeDefined(); + expect(pass.suggestions.some(s => s.includes('read results'))).toBe(true); + }); + + it('DR-003 pass with master role has no suggestions', async () => { + const findings = await check('web_master'); + const pass = findings.find(f => f.category === 'token_auth_pass'); + expect(pass).toBeDefined(); + expect(pass.suggestions).toBeUndefined(); + }); + + // ── Authentication failures ─────────────────────────────────────────────── + + it('returns token_auth_fail fail when token gets 401', async () => { + const findings = await check('invalid_token'); + const fail = findings.find(f => f.category === 'token_auth_fail'); + expect(fail).toBeDefined(); + expect(fail.status).toBe('fail'); + expect(fail.message).toContain('401'); + expect(fail.suggestions.length).toBeGreaterThan(0); + }); + + it('returns token_auth_fail fail when token gets 403', async () => { + const findings = await check('forbidden_token'); + const fail = findings.find(f => f.category === 'token_auth_fail'); + expect(fail).toBeDefined(); + expect(fail.status).toBe('fail'); + expect(fail.message).toContain('403'); + }); + + it('includes suggestions for 401 auth failure', async () => { + const findings = await check('invalid_token'); + const fail = findings.find(f => f.category === 'token_auth_fail'); + expect(fail.suggestions.some(s => s.includes('expired'))).toBe(true); + expect(fail.suggestions.some(s => s.includes('Project Settings'))).toBe(true); + }); + + it('returns token_auth_unexpected_status warn for unexpected HTTP status', async () => { + const findings = await check('weird_token'); + const warn = findings.find(f => f.category === 'token_auth_unexpected_status'); + expect(warn).toBeDefined(); + expect(warn.status).toBe('warn'); + expect(warn.message).toContain('unexpected HTTP'); + }); + + it('returns token_auth_network_error warn when API is unreachable', async () => { + const findings = await withEnv( + { PERCY_TOKEN: 'web_test_token' }, + () => checkAuth({ timeout: 1000, apiBaseUrl: 'http://127.0.0.1:1' }) + ); + const networkWarn = findings.find(f => f.category === 'token_auth_network_error'); + expect(networkWarn).toBeDefined(); + expect(networkWarn.status).toBe('warn'); + expect(networkWarn.message).toContain('could not reach Percy API'); + expect(networkWarn.suggestions.some(s => s.includes('network issue'))).toBe(true); + }); + + it('handles malformed JSON body gracefully — still passes auth', async () => { + const findings = await check('malformed_body_token'); + const pass = findings.find(f => f.category === 'token_auth_pass'); + expect(pass).toBeDefined(); + expect(pass.status).toBe('pass'); + const info = findings.find(f => f.category === 'token_type_info'); + expect(info.metadata.projectTokenType).toBe('unknown'); + expect(info.metadata.role).toBe('unknown'); + }); + + // ── SECURITY: token never in output ────────────────────────────────────── + + it('never includes token value in any finding message', async () => { + const token = 'web_master'; + const findings = await check(token); + const allText = findings.map(f => + `${f.message} ${(f.suggestions || []).join(' ')}` + ).join(' '); + // The raw token string must not appear anywhere + expect(allText).not.toContain('web_master'); + }); + + it('sanitizes raw token value from error messages in non-standard formats', async () => { + const token = 'web_leaked_in_error_msg'; + const findings = await withEnv( + { PERCY_TOKEN: token }, + () => checkAuth({ timeout: 1000, apiBaseUrl: 'http://127.0.0.1:1' }) + ); + const allText = findings.map(f => + `${f.message} ${(f.suggestions || []).join(' ')}` + ).join(' '); + expect(allText).not.toContain('leaked_in_error'); + expect(allText).not.toContain(token); + }); + + // ── Outer catch — prober throws unexpectedly ────────────────────────────── + + it('returns token_auth_network_error warn when probeUrl throws instead of returning', async () => { + spyOn(HttpProber.prototype, 'probeUrl').and.rejectWith(new Error('socket hang up')); + const findings = await withEnv( + { PERCY_TOKEN: 'any_token' }, + () => checkAuth() + ); + const warn = findings.find(f => f.category === 'token_auth_network_error'); + expect(warn).toBeDefined(); + expect(warn.status).toBe('warn'); + expect(warn.message).toContain('could not reach Percy API'); + expect(warn.suggestions.some(s => s.includes('network issue'))).toBe(true); + }); +}); diff --git a/packages/cli-doctor/test/config.test.js b/packages/cli-doctor/test/config.test.js new file mode 100644 index 000000000..5167878d6 --- /dev/null +++ b/packages/cli-doctor/test/config.test.js @@ -0,0 +1,302 @@ +/** + * Tests for packages/cli-doctor/src/checks/config.js + * + * Injects a mock searchFn to simulate various config scenarios. + * No file system access required. + */ + +import { checkConfig } from '../src/checks/config.js'; +import { withEnv } from './helpers.js'; + +// Helper to create a searchFn that returns a fixed value +function mockSearch(returnValue) { + return () => returnValue; +} + +// Helper to create a searchFn that throws +function throwingSearch(message) { + return () => { throw new Error(message); }; +} + +describe('checkConfig', () => { + // ── No config file ────────────────────────────────────────────────────────── + + it('returns config_not_found info when no config file is found', async () => { + const findings = await checkConfig({ searchFn: mockSearch(null) }); + expect(findings.length).toBe(1); + expect(findings[0].category).toBe('config_not_found'); + expect(findings[0].status).toBe('info'); + expect(findings[0].message).toContain('No Percy configuration file detected'); + }); + + it('returns config_not_found info when search returns empty config', async () => { + const findings = await checkConfig({ searchFn: mockSearch({ config: null, filepath: '' }) }); + expect(findings.length).toBe(1); + expect(findings[0].category).toBe('config_not_found'); + expect(findings[0].status).toBe('info'); + }); + + // ── Config load error ─────────────────────────────────────────────────────── + + it('returns config_parse_error fail when config file has syntax errors', async () => { + const findings = await checkConfig({ + searchFn: throwingSearch('YAML parse error: unexpected token at line 5') + }); + expect(findings.length).toBe(1); + expect(findings[0].category).toBe('config_parse_error'); + expect(findings[0].status).toBe('fail'); + expect(findings[0].message).toContain('YAML parse error'); + expect(findings[0].suggestions).toBeDefined(); + expect(findings[0].suggestions.length).toBeGreaterThan(0); + }); + + // ── Config file found ─────────────────────────────────────────────────────── + + it('returns config_found pass when config file is found', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => + checkConfig({ + searchFn: mockSearch({ config: { version: 2 }, filepath: '/project/.percy.yml' }) + }) + ); + expect(findings.length).toBe(1); + expect(findings[0].category).toBe('config_found'); + expect(findings[0].status).toBe('pass'); + expect(findings[0].message).toContain('.percy.yml'); + }); + + // ── Version validation ────────────────────────────────────────────────────── + + it('returns config_version_invalid warn when version is missing', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => + checkConfig({ + searchFn: mockSearch({ config: {}, filepath: '/project/.percy.yml' }) + }) + ); + const versionFinding = findings.find(f => f.category === 'config_version_invalid'); + expect(versionFinding).toBeDefined(); + expect(versionFinding.status).toBe('warn'); + expect(versionFinding.message).toContain('missing or invalid version'); + }); + + it('returns config_version_invalid warn when version is non-numeric', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => + checkConfig({ + searchFn: mockSearch({ config: { version: 'abc' }, filepath: '/project/.percy.yml' }) + }) + ); + const versionFinding = findings.find(f => f.category === 'config_version_invalid'); + expect(versionFinding).toBeDefined(); + expect(versionFinding.status).toBe('warn'); + }); + + it('returns config_version_outdated warn when config uses version 1', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => + checkConfig({ + searchFn: mockSearch({ config: { version: 1 }, filepath: '/project/.percy.yml' }) + }) + ); + const versionFinding = findings.find(f => f.category === 'config_version_outdated'); + expect(versionFinding).toBeDefined(); + expect(versionFinding.status).toBe('warn'); + expect(versionFinding.message).toContain('outdated format (version 1)'); + expect(versionFinding.suggestions).toContain('Run: percy config:migrate to update to the latest format.'); + }); + + it('returns config_version_outdated warn with correct version number for version 0', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => + checkConfig({ + searchFn: mockSearch({ config: { version: 0 }, filepath: '/project/.percy.yml' }) + }) + ); + const versionFinding = findings.find(f => f.category === 'config_version_outdated'); + expect(versionFinding).toBeDefined(); + expect(versionFinding.message).toContain('outdated format (version 0)'); + }); + + it('does not warn when config uses version 2', async () => { + const findings = await withEnv({ PERCY_TOKEN: undefined }, () => + checkConfig({ + searchFn: mockSearch({ config: { version: 2 }, filepath: '/project/.percy.yml' }) + }) + ); + const versionWarns = findings.filter(f => + f.category === 'config_version_invalid' || f.category === 'config_version_outdated' + ); + expect(versionWarns.length).toBe(0); + }); + + // ── Project-type config mismatches ────────────────────────────────────────── + + it('returns config_key_automate_only warn for automate-only keys with web token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'web_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { fullPage: true, freezeAnimation: true } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_automate_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.status).toBe('warn'); + expect(mismatch.message).toContain('fullPage'); + expect(mismatch.message).toContain('freezeAnimation'); + expect(mismatch.message).toContain('web'); + }); + + it('returns config_key_automate_only warn for automate-only keys with app token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'app_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { ignoreRegions: [{ selector: '.ad' }] } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_automate_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.message).toContain('ignoreRegions'); + expect(mismatch.message).toContain('app'); + }); + + it('does NOT return config_key_automate_only for automate-only keys with auto token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'auto_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { fullPage: true } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_automate_only'); + expect(mismatch).toBeUndefined(); + }); + + it('returns config_key_web_only warn for web-only keys with automate token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'auto_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { waitForTimeout: 5000, waitForSelector: '.loaded' } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_web_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.status).toBe('warn'); + expect(mismatch.message).toContain('waitForTimeout'); + expect(mismatch.message).toContain('waitForSelector'); + expect(mismatch.message).toContain('automate'); + }); + + it('returns config_key_web_only warn for web-only keys with app token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'app_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { waitForTimeout: 3000 } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_web_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.message).toContain('app'); + }); + + it('does NOT return config_key_web_only for web-only keys with web token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'web_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { waitForTimeout: 5000 } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_web_only'); + expect(mismatch).toBeUndefined(); + }); + + it('returns config_key_web_only warn for web-only keys with ss_ (generic) token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'ss_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { waitForTimeout: 5000 } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_web_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.message).toContain('generic'); + }); + + it('returns config_key_automate_only with correct type for ss_ (generic) token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'ss_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { fullPage: true } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_automate_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.message).toContain('generic'); + }); + + it('returns config_key_automate_only with correct type for vmw_ (visual_scanner) token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'vmw_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { freezeAnimation: true } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_automate_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.message).toContain('visual_scanner'); + }); + + it('returns config_key_automate_only with correct type for res_ (responsive_scanner) token', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'res_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ + config: { version: 2, snapshot: { ignoreRegions: [{}] } }, + filepath: '/project/.percy.yml' + }) + }) + ); + const mismatch = findings.find(f => f.category === 'config_key_automate_only'); + expect(mismatch).toBeDefined(); + expect(mismatch.message).toContain('responsive_scanner'); + }); + + // ── No mismatch when snapshot section is empty ───────────────────────────── + + it('skips mismatch checks when snapshot config is absent', async () => { + const findings = await withEnv({ PERCY_TOKEN: 'web_abc123' }, () => + checkConfig({ + searchFn: mockSearch({ config: { version: 2 }, filepath: '/project/.percy.yml' }) + }) + ); + const mismatches = findings.filter(f => + f.category === 'config_key_automate_only' || f.category === 'config_key_web_only' + ); + expect(mismatches.length).toBe(0); + }); + + // ── Suggestions quality ──────────────────────────────────────────────────── + + it('includes actionable suggestions for no config file', async () => { + const findings = await checkConfig({ searchFn: mockSearch(null) }); + const suggestions = findings[0].suggestions; + expect(suggestions).toBeDefined(); + expect(suggestions.some(s => s.includes('.percy.yml'))).toBe(true); + }); + + it('includes config:validate suggestion for load errors', async () => { + const findings = await checkConfig({ searchFn: throwingSearch('bad yaml') }); + expect(findings[0].suggestions.some(s => s.includes('config:validate'))).toBe(true); + }); +}); diff --git a/packages/cli-doctor/test/env-audit.test.js b/packages/cli-doctor/test/env-audit.test.js new file mode 100644 index 000000000..73d84776e --- /dev/null +++ b/packages/cli-doctor/test/env-audit.test.js @@ -0,0 +1,367 @@ +/** + * Tests for packages/cli-doctor/src/checks/env-audit.js + * + * checkEnvAndCI is the single combined entry point covering: + * PART A: system info (via @percy/monitoring) + Percy env-var audit + * PART B: CI detection (merged from former ci.js) + * + * Tests inject: + * - monitoringInstance to control system-info output without real syscalls + * - percyEnv to control CI detection without GITHUB_EVENT_PATH cache + * + * Uses withEnv to control process.env for env-audit assertions. + */ + +import { checkEnvAndCI } from '../src/checks/env-audit.js'; +import { withEnv } from './helpers.js'; +import cp from 'child_process'; + +// ── Mock helpers ────────────────────────────────────────────────────────────── + +/** Monitoring mock that skips real syscalls and returns null (no env_system_info). */ +const nullMonitoring = { + getPercyEnv: () => ({}), + logSystemInfo: async () => null +}; + +/** Monitoring mock that returns a fixed system info object (emits env_system_info). */ +const mockSystemInfo = { + os: { platform: 'linux', type: 'Linux', release: '5.15.0' }, + cpu: { name: 'Intel Xeon', arch: 'x64', cores: 4 }, + disk: { available: '50 GB' }, + memory: { totalGb: 8.0, swapGb: 2.0 }, + containerInfo: { isContainer: false, isPod: false, isMachine: true }, + percyEnvs: {} +}; + +const systemMonitoring = { + getPercyEnv: () => ({}), + logSystemInfo: async () => mockSystemInfo +}; + +/** Monitoring mock whose logSystemInfo rejects (non-fatal path). */ +const failingMonitoring = { + getPercyEnv: () => ({}), + logSystemInfo: async () => { throw new Error('systeminformation unavailable'); } +}; + +/** Not-in-CI PercyEnv mock. */ +const noCI = { ci: null, commit: null, branch: null }; + +/** In-CI PercyEnv mock (GitHub Actions). */ +function ciEnv({ commit = 'abc123def4567890', branch = 'main' } = {}) { + return { ci: 'github', commit, branch }; +} + +// ── Base env that clears all Percy vars ─────────────────────────────────────── +const CLEAN_PERCY_ENV = { + PERCY_TOKEN: undefined, + PERCY_BUILD_ID: undefined, + PERCY_BUILD_URL: undefined, + PERCY_BROWSER_EXECUTABLE: undefined, + PERCY_CHROMIUM_BASE_URL: undefined, + PERCY_PAC_FILE_URL: undefined, + PERCY_CLIENT_API_URL: undefined, + PERCY_SERVER_ADDRESS: undefined, + PERCY_SERVER_HOST: undefined, + PERCY_COMMIT: undefined, + PERCY_BRANCH: undefined, + PERCY_PULL_REQUEST: undefined, + PERCY_TARGET_COMMIT: undefined, + PERCY_TARGET_BRANCH: undefined, + PERCY_PARALLEL_TOTAL: undefined, + PERCY_PARALLEL_NONCE: undefined, + PERCY_PARTIAL_BUILD: undefined, + PERCY_AUTO_ENABLED_GROUP_BUILD: undefined, + PERCY_DEBUG: undefined, + PERCY_LOGLEVEL: undefined, + PERCY_GZIP: undefined, + PERCY_IGNORE_DUPLICATES: undefined, + PERCY_IGNORE_TIMEOUT_ERROR: undefined, + PERCY_SNAPSHOT_UPLOAD_CONCURRENCY: undefined, + PERCY_RESOURCE_UPLOAD_CONCURRENCY: undefined, + PERCY_NETWORK_IDLE_WAIT_TIMEOUT: undefined, + PERCY_PAGE_LOAD_TIMEOUT: undefined, + PERCY_DISABLE_SYSTEM_MONITORING: undefined, + PERCY_METRICS: undefined, + PERCY_SKIP_GIT_CHECK: undefined, + PERCY_DISABLE_DOTENV: undefined, + PERCY_EXIT_WITH_ZERO_ON_ERROR: undefined, + PERCY_AUTO_DOCTOR: undefined, + NODE_TLS_REJECT_UNAUTHORIZED: undefined +}; + +// Shorthand: run checkEnvAndCI with null monitoring + no-CI env injected +function run(envOverrides = {}, percyEnvOverride = noCI, monOverride = nullMonitoring) { + return withEnv(envOverrides, () => + checkEnvAndCI({ monitoringInstance: monOverride, percyEnv: percyEnvOverride }) + ); +} + +// ── System info (env_system_info) ─────────────────────────────────────────────── + +describe('checkEnvAndCI — system info', () => { + it('emits env_system_info info when monitoring.logSystemInfo returns data', async () => { + const findings = await withEnv(CLEAN_PERCY_ENV, () => + checkEnvAndCI({ monitoringInstance: systemMonitoring, percyEnv: noCI }) + ); + const f = findings.find(f => f.category === 'env_system_info'); + expect(f).toBeDefined(); + expect(f.status).toBe('info'); + expect(f.message).toContain('linux'); + expect(f.message).toContain('4 CPU core(s)'); + expect(f.message).toContain('8.0GB RAM'); + expect(f.metadata.cpu.cores).toBe(4); + expect(f.metadata.memory.totalGb).toBe(8.0); + }); + + it('skips env_system_info when monitoring.logSystemInfo returns null', async () => { + const findings = await run(CLEAN_PERCY_ENV); + expect(findings.find(f => f.category === 'env_system_info')).toBeUndefined(); + }); + + it('skips env_system_info when monitoring.logSystemInfo throws', async () => { + const findings = await withEnv(CLEAN_PERCY_ENV, () => + checkEnvAndCI({ monitoringInstance: failingMonitoring, percyEnv: noCI }) + ); + expect(findings.find(f => f.category === 'env_system_info')).toBeUndefined(); + // Should still return env-audit findings + expect(findings.find(f => f.category === 'env_no_percy_vars')).toBeDefined(); + }); + + it('uses systemInfo.percyEnvs from logSystemInfo as the Percy env source', async () => { + const monitoring = { + getPercyEnv: () => ({ PERCY_GZIP: '1' }), // would be used as fallback + logSystemInfo: async () => ({ + ...mockSystemInfo, + percyEnvs: { PERCY_DEBUG: 'percy:*', PERCY_LOGLEVEL: 'debug' } + }) + }; + const findings = await withEnv(CLEAN_PERCY_ENV, () => + checkEnvAndCI({ monitoringInstance: monitoring, percyEnv: noCI }) + ); + const listing = findings.find(f => f.category === 'env_vars_listed'); + expect(listing).toBeDefined(); + expect(listing.message).toContain('PERCY_DEBUG'); + expect(listing.message).toContain('PERCY_LOGLEVEL'); + // PERCY_GZIP was in getPercyEnv() but systemInfo.percyEnvs takes precedence + expect(listing.message).not.toContain('PERCY_GZIP'); + }); +}); + +// ── No Percy vars (env_no_percy_vars) ───────────────────────────────────────────── + +describe('checkEnvAndCI — env var listing', () => { + it('returns env_no_percy_vars info when no Percy vars are set', async () => { + const findings = await run(CLEAN_PERCY_ENV); + const f = findings.find(f => f.category === 'env_no_percy_vars'); + expect(f).toBeDefined(); + expect(f.status).toBe('info'); + expect(f.message).toContain('No Percy-specific environment variables detected'); + }); + + it('returns env_vars_listed listing set vars', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_TOKEN: 'test', PERCY_DEBUG: 'true' }); + const f = findings.find(f => f.category === 'env_vars_listed'); + expect(f).toBeDefined(); + expect(f.status).toBe('info'); + expect(f.message).toContain('PERCY_TOKEN'); + expect(f.message).toContain('PERCY_DEBUG'); + }); + + it('includes PERCY_AUTO_DOCTOR in listed vars', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_AUTO_DOCTOR: 'true' }); + const f = findings.find(f => f.category === 'env_vars_listed'); + expect(f).toBeDefined(); + expect(f.message).toContain('PERCY_AUTO_DOCTOR'); + }); + + // ── PERCY_PARALLEL_TOTAL validation ──────────────────────────────────────── + + it('returns env_parallel_total_invalid fail when PERCY_PARALLEL_TOTAL is not a valid integer', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: 'abc' }); + const f = findings.find(f => f.category === 'env_parallel_total_invalid'); + expect(f).toBeDefined(); + expect(f.status).toBe('fail'); + expect(f.message).toContain('PERCY_PARALLEL_TOTAL'); + }); + + it('returns env_parallel_total_invalid fail when PERCY_PARALLEL_TOTAL is zero', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: '0' }); + expect(findings.find(f => f.category === 'env_parallel_total_invalid')).toBeDefined(); + }); + + it('returns env_parallel_total_invalid fail when PERCY_PARALLEL_TOTAL is negative', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: '-3' }); + expect(findings.find(f => f.category === 'env_parallel_total_invalid')).toBeDefined(); + }); + + it('returns env_parallel_total_invalid fail when PERCY_PARALLEL_TOTAL is a float', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: '4.5' }); + expect(findings.find(f => f.category === 'env_parallel_total_invalid')).toBeDefined(); + }); + + it('does not fail when PERCY_PARALLEL_TOTAL is a valid positive integer', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: '4' }); + expect(findings.find(f => f.category === 'env_parallel_total_invalid')).toBeUndefined(); + }); + + // ── Manual overrides ──────────────────────────────────────────────────────── + + it('returns env_manual_overrides info when manual overrides are active', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_COMMIT: 'abc123', PERCY_BRANCH: 'main' }); + const f = findings.find(f => f.category === 'env_manual_overrides'); + expect(f).toBeDefined(); + expect(f.status).toBe('info'); + expect(f.message).toContain('PERCY_COMMIT'); + expect(f.message).toContain('PERCY_BRANCH'); + expect(f.message).toContain('Manual overrides'); + }); + + it('does not warn about overrides when none are set', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_TOKEN: 'test' }); + expect(findings.find(f => f.category === 'env_manual_overrides')).toBeUndefined(); + }); + + // ── NODE_TLS_REJECT_UNAUTHORIZED ─────────────────────────────────────────── + + it('returns env_tls_disabled warn when NODE_TLS_REJECT_UNAUTHORIZED=0', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, NODE_TLS_REJECT_UNAUTHORIZED: '0' }); + const f = findings.find(f => f.category === 'env_tls_disabled'); + expect(f).toBeDefined(); + expect(f.status).toBe('warn'); + expect(f.message).toContain('NODE_TLS_REJECT_UNAUTHORIZED=0'); + expect(f.suggestions.some(s => s.includes('NODE_EXTRA_CA_CERTS'))).toBe(true); + }); + + it('does not warn when NODE_TLS_REJECT_UNAUTHORIZED is 1', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, NODE_TLS_REJECT_UNAUTHORIZED: '1' }); + expect(findings.find(f => f.category === 'env_tls_disabled')).toBeUndefined(); + }); + + it('does not warn when NODE_TLS_REJECT_UNAUTHORIZED is unset', async () => { + const findings = await run({ ...CLEAN_PERCY_ENV, NODE_TLS_REJECT_UNAUTHORIZED: undefined }); + expect(findings.find(f => f.category === 'env_tls_disabled')).toBeUndefined(); + }); + + // ── SECURITY: no env var values in output ────────────────────────────────── + + it('never includes PERCY_TOKEN value in findings', async () => { + const secret = 'web_my_super_secret_token_value'; + const findings = await run({ ...CLEAN_PERCY_ENV, PERCY_TOKEN: secret }); + const allText = findings.map(f => + `${f.message} ${(f.suggestions || []).join(' ')}` + ).join(' '); + expect(allText).not.toContain('my_super_secret'); + expect(allText).not.toContain('token_value'); + expect(allText).toContain('PERCY_TOKEN'); // name is fine + }); +}); + +// ── CI check (merged from ci.js) ───────────────────────────────────────────── + +describe('checkEnvAndCI — CI section', () => { + it('adds ci_not_detected when not in CI and no CI check results follow', async () => { + const findings = await run(CLEAN_PERCY_ENV, noCI); + expect(findings.find(f => f.category === 'ci_not_detected')).toBeDefined(); + // CI-specific findings must not appear + expect(findings.find(f => f.category === 'ci_detected')).toBeUndefined(); + expect(findings.find(f => f.category === 'ci_commit_missing')).toBeUndefined(); + }); + + it('adds ci_detected when CI is detected', async () => { + const findings = await run(CLEAN_PERCY_ENV, ciEnv()); + const f = findings.find(f => f.category === 'ci_detected'); + expect(f).toBeDefined(); + expect(f.status).toBe('pass'); + expect(f.message).toContain('github'); + }); + + it('adds ci_commit_found when commit is present', async () => { + const findings = await run(CLEAN_PERCY_ENV, ciEnv({ commit: 'deadbeef1234' })); + const f = findings.find(f => f.category === 'ci_commit_found'); + expect(f).toBeDefined(); + expect(f.message).toContain('deadbeef1234'); + }); + + it('adds ci_commit_missing when commit is null', async () => { + const findings = await run(CLEAN_PERCY_ENV, ciEnv({ commit: null })); + expect(findings.find(f => f.category === 'ci_commit_missing')).toBeDefined(); + expect(findings.find(f => f.category === 'ci_commit_found')).toBeUndefined(); + }); + + it('adds ci_branch_missing when branch is null', async () => { + const findings = await run(CLEAN_PERCY_ENV, ciEnv({ branch: null })); + expect(findings.find(f => f.category === 'ci_branch_missing')).toBeDefined(); + }); + + it('combines env-audit findings with CI findings in one call', async () => { + const findings = await run( + { ...CLEAN_PERCY_ENV, PERCY_TOKEN: 'web_xxx', NODE_TLS_REJECT_UNAUTHORIZED: '0' }, + ciEnv() + ); + // Env audit findings + expect(findings.find(f => f.category === 'env_vars_listed')).toBeDefined(); // vars listed + expect(findings.find(f => f.category === 'env_tls_disabled')).toBeDefined(); // TLS warn + // CI findings + expect(findings.find(f => f.category === 'ci_detected')).toBeDefined(); // CI detected + expect(findings.find(f => f.category === 'ci_commit_found')).toBeDefined(); // commit present + }); + + // ── Parallel config (DR-205 / DR-206) ────────────────────────────────────── + + it('adds ci_parallel_nonce_missing warn when PERCY_PARALLEL_TOTAL is set without PERCY_PARALLEL_NONCE in CI', async () => { + const findings = await run( + { ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: '4', PERCY_PARALLEL_NONCE: undefined }, + ciEnv() + ); + const warn = findings.find(f => f.category === 'ci_parallel_nonce_missing'); + expect(warn).toBeDefined(); + expect(warn.status).toBe('warn'); + expect(warn.message).toContain('PERCY_PARALLEL_NONCE'); + expect(findings.find(f => f.category === 'ci_parallel_config_valid')).toBeUndefined(); + }); + + it('adds ci_parallel_config_valid pass when both PERCY_PARALLEL_TOTAL and PERCY_PARALLEL_NONCE are set in CI', async () => { + const findings = await run( + { ...CLEAN_PERCY_ENV, PERCY_PARALLEL_TOTAL: '4', PERCY_PARALLEL_NONCE: 'build-42' }, + ciEnv() + ); + const pass = findings.find(f => f.category === 'ci_parallel_config_valid'); + expect(pass).toBeDefined(); + expect(pass.status).toBe('pass'); + expect(pass.message).toContain('PERCY_PARALLEL_TOTAL'); + expect(pass.message).toContain('PERCY_PARALLEL_NONCE'); + expect(findings.find(f => f.category === 'ci_parallel_nonce_missing')).toBeUndefined(); + }); + + // ── Git availability (DR-207 / DR-208 / DR-209) ──────────────────────────── + + it('adds ci_git_check_skipped info when git is unavailable and PERCY_SKIP_GIT_CHECK=true', async () => { + spyOn(cp, 'execSync').and.throwError('git: command not found'); + const findings = await run( + { ...CLEAN_PERCY_ENV, PERCY_SKIP_GIT_CHECK: 'true' }, + ciEnv() + ); + const info = findings.find(f => f.category === 'ci_git_check_skipped'); + expect(info).toBeDefined(); + expect(info.status).toBe('info'); + expect(info.message).toContain('PERCY_SKIP_GIT_CHECK=true'); + expect(findings.find(f => f.category === 'ci_git_available')).toBeUndefined(); + expect(findings.find(f => f.category === 'ci_git_unavailable')).toBeUndefined(); + }); + + it('adds ci_git_unavailable warn when git is unavailable and PERCY_SKIP_GIT_CHECK is not set', async () => { + spyOn(cp, 'execSync').and.throwError('git: command not found'); + const findings = await run( + { ...CLEAN_PERCY_ENV, PERCY_SKIP_GIT_CHECK: undefined }, + ciEnv() + ); + const warn = findings.find(f => f.category === 'ci_git_unavailable'); + expect(warn).toBeDefined(); + expect(warn.status).toBe('warn'); + expect(warn.message).toContain('Git is not available'); + expect(findings.find(f => f.category === 'ci_git_available')).toBeUndefined(); + expect(findings.find(f => f.category === 'ci_git_check_skipped')).toBeUndefined(); + }); +}); diff --git a/packages/cli-doctor/test/utils/helpers.test.js b/packages/cli-doctor/test/utils/helpers.test.js index 78b08df6a..fb6f14222 100644 --- a/packages/cli-doctor/test/utils/helpers.test.js +++ b/packages/cli-doctor/test/utils/helpers.test.js @@ -544,13 +544,13 @@ describe('runDiagnostics', () => { it('rejects when timeout is non-numeric (equivalent to --timeout abc)', async () => { spyAllCheckers(); await expectAsync(runDiagnostics({ timeout: 'abc' })) - .toBeRejectedWithError('--timeout must be a positive integer (milliseconds)'); + .toBeRejectedWithError('--timeout must be a positive integer up to 300000ms (5 minutes)'); }); it('rejects when timeout is <= 0 (equivalent to --timeout -1)', async () => { spyAllCheckers(); await expectAsync(runDiagnostics({ timeout: -1 })) - .toBeRejectedWithError('--timeout must be a positive integer (milliseconds)'); + .toBeRejectedWithError('--timeout must be a positive integer up to 300000ms (5 minutes)'); }); }); diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 7adf72059..119958988 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -868,24 +868,21 @@ export class PercyClient { // decides project type tokenType() { let token = this.getToken(false) || ''; + return tokenType(token.split('_')[0]); + } +} - const type = token.split('_')[0]; - switch (type) { - case 'auto': - return 'automate'; - case 'web': - return 'web'; - case 'app': - return 'app'; - case 'ss': - return 'generic'; - case 'vmw': - return 'visual_scanner'; - case 'res': - return 'responsive_scanner'; - default: - return 'web'; - } +// Maps a token prefix to its project type string. +// Exported so other packages can reuse without duplicating the mapping. +export function tokenType(prefix) { + switch (prefix) { + case 'auto': return 'automate'; + case 'web': return 'web'; + case 'app': return 'app'; + case 'ss': return 'generic'; + case 'vmw': return 'visual_scanner'; + case 'res': return 'responsive_scanner'; + default: return 'web'; } } diff --git a/packages/client/src/index.js b/packages/client/src/index.js index 5ac24ab14..8a1445169 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -1 +1 @@ -export { default, PercyClient } from './client.js'; +export { default, PercyClient, tokenType } from './client.js'; diff --git a/packages/core/package.json b/packages/core/package.json index 986e19859..83247e791 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -60,5 +60,8 @@ "rimraf": "^3.0.2", "ws": "^8.17.1", "yaml": "^2.4.1" + }, + "optionalDependencies": { + "@percy/cli-doctor": "1.31.10-beta.2" } } diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index d66a5c5c6..b93be4ddb 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -328,6 +328,40 @@ function mergeSnapshotOptions(prev = {}, next) { }); } +// Runs quick doctor diagnostics after a build failure, guarded by PERCY_AUTO_DOCTOR env var. +// Uses dynamic import so @percy/cli-doctor is not a hard dependency of @percy/core. +async function runDoctorOnFailure(percy) { + // Guard against double-fire: build-creation failure triggers this in the + // 'start' catch, and the subsequent 'end' handler (no build id) would fire + // it again — each quick-mode run carries up to 8s of network latency. + if (percy._doctorRanOnFailure) return; + percy._doctorRanOnFailure = true; + + if (process.env.PERCY_AUTO_DOCTOR !== 'true') { + percy.log.warn('Run `percy doctor` to diagnose connectivity and token issues.'); + return; + } + + percy.log.info('[percy doctor] Running quick diagnostics after build failure...'); + try { + // eslint-disable-next-line import/no-extraneous-dependencies + const { runDiagnostics } = await import('@percy/cli-doctor'); + const report = await runDiagnostics({ mode: 'quick', timeout: 8000 }); + + const failed = report?.checks?.connectivity?.status === 'fail' || + report?.checks?.auth?.status === 'fail'; + + if (failed) { + percy.log.warn('[percy doctor] Quick check found issues — see above for details.'); + } else { + percy.log.info('[percy doctor] Connectivity and token look healthy — the failure may be server-side.'); + } + } catch { + // doctor not installed or import failed — degrade silently + percy.log.debug('[percy doctor] Could not run automatic diagnostics (package not available).'); + } +} + // Creates a snapshots queue that manages a Percy build and uploads snapshots. export function createSnapshotsQueue(percy) { let { concurrency } = percy.config.discovery; @@ -360,6 +394,7 @@ export function createSnapshotsQueue(percy) { Object.assign(build, { error: 'Failed to create build' }); percy.log.error(build.error); percy.log.error(err); + await runDoctorOnFailure(percy); queue.close(true); } }) @@ -369,11 +404,13 @@ export function createSnapshotsQueue(percy) { if (build?.failed) { percy.log.warn(`Build #${build.number} failed: ${build.url}`, { build }); + await runDoctorOnFailure(percy); } else if (build?.id) { await percy.client.finalizeBuild(build.id); percy.log.info(`Finalized build #${build.number}: ${build.url}`, { build }); } else { percy.log.warn('Build not created', { build }); + await runDoctorOnFailure(percy); } }) // snapshots are unique by name and testCase both diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index cfb6b5bb7..0720c13a4 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -2065,3 +2065,157 @@ describe('Snapshot', () => { }); }); }); + +// ── runDoctorOnFailure ──────────────────────────────────────────────────────── +// Exercises all branches of the auto-doctor helper that fires after a build +// failure. Uses global.__MOCK_IMPORTS__ (the ESM loader hook) to mock +// @percy/cli-doctor without making it a hard dependency. + +describe('runDoctorOnFailure', () => { + let percy; + + beforeEach(async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + process.env.PERCY_DISABLE_SYSTEM_MONITORING = 'true'; + await setupTest(); + }); + + afterEach(async () => { + delete process.env.PERCY_AUTO_DOCTOR; + delete process.env.PERCY_DISABLE_SYSTEM_MONITORING; + global.__MOCK_IMPORTS__?.delete('@percy/cli-doctor'); + await percy?.stop(true); + }); + + // Helper: start percy with deferUploads so build creation is deferred, then + // trigger it with a failing /builds reply so the queue 'start' catch fires. + async function triggerBuildCreateFailure() { + api.reply('/builds', () => [401, { errors: [{ detail: 'unauthorized' }] }]); + + percy = new Percy({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + deferUploads: true + }); + await percy.start(); + + // Queue a dom snapshot so the queue has items to flush + await percy.snapshot({ + name: 'test', + url: 'http://localhost:8000', + domSnapshot: '' + }).catch(() => {}); + + // stop() triggers the deferred flush; build creation fails here + await percy.stop().catch(() => {}); + } + + it('logs a plain warn when PERCY_AUTO_DOCTOR is not set', async () => { + delete process.env.PERCY_AUTO_DOCTOR; + await triggerBuildCreateFailure(); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] Run `percy doctor` to diagnose connectivity and token issues.' + ])); + }); + + it('logs a plain warn when PERCY_AUTO_DOCTOR is set to a non-true value', async () => { + process.env.PERCY_AUTO_DOCTOR = 'false'; + await triggerBuildCreateFailure(); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] Run `percy doctor` to diagnose connectivity and token issues.' + ])); + }); + + it('runs quick diagnostics and logs warn when checks fail', async () => { + process.env.PERCY_AUTO_DOCTOR = 'true'; + + const runDiagnostics = jasmine.createSpy('runDiagnostics').and.resolveTo({ + checks: { + connectivity: { status: 'fail' }, + auth: { status: 'pass' } + } + }); + global.__MOCK_IMPORTS__.set('@percy/cli-doctor', { runDiagnostics }); + + await triggerBuildCreateFailure(); + + expect(runDiagnostics).toHaveBeenCalledWith({ mode: 'quick', timeout: 8000 }); + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] [percy doctor] Running quick diagnostics after build failure...' + ])); + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] [percy doctor] Quick check found issues — see above for details.' + ])); + }); + + it('logs info when quick diagnostics find no issues', async () => { + process.env.PERCY_AUTO_DOCTOR = 'true'; + + const runDiagnostics = jasmine.createSpy('runDiagnostics').and.resolveTo({ + checks: { + connectivity: { status: 'pass' }, + auth: { status: 'pass' } + } + }); + global.__MOCK_IMPORTS__.set('@percy/cli-doctor', { runDiagnostics }); + + await triggerBuildCreateFailure(); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] [percy doctor] Connectivity and token look healthy — the failure may be server-side.' + ])); + }); + + it('degrades silently when @percy/cli-doctor has no runDiagnostics export', async () => { + process.env.PERCY_AUTO_DOCTOR = 'true'; + + // Simulate package not available: no runDiagnostics export → TypeError on call + // The catch block in runDoctorOnFailure swallows it and logs debug. + global.__MOCK_IMPORTS__.set('@percy/cli-doctor', {}); + + await triggerBuildCreateFailure(); + + // Catch block fires — no issue/warn about doctor should appear + expect(logger.stderr).not.toEqual(jasmine.arrayContaining([ + jasmine.stringMatching('\\[percy doctor\\]') + ])); + }); + + it('degrades silently when runDiagnostics itself throws', async () => { + process.env.PERCY_AUTO_DOCTOR = 'true'; + + const runDiagnostics = jasmine.createSpy('runDiagnostics').and.rejectWith( + new Error('network unavailable') + ); + global.__MOCK_IMPORTS__.set('@percy/cli-doctor', { runDiagnostics }); + + await triggerBuildCreateFailure(); + + // catch block fires — should not propagate the error + expect(logger.stderr).not.toEqual(jasmine.arrayContaining([ + jasmine.stringMatching('network unavailable') + ])); + }); + + // P1: double-fire guard — build-creation failure hits the 'start' catch + // AND the 'end' handler's "Build not created" branch; the + // _doctorRanOnFailure flag must ensure runDiagnostics is called only once. + it('calls runDiagnostics exactly once even when both start-catch and end-handler fire', async () => { + process.env.PERCY_AUTO_DOCTOR = 'true'; + + const runDiagnostics = jasmine.createSpy('runDiagnostics').and.resolveTo({ + checks: { connectivity: { status: 'pass' }, auth: { status: 'pass' } } + }); + global.__MOCK_IMPORTS__.set('@percy/cli-doctor', { runDiagnostics }); + + await triggerBuildCreateFailure(); + + // Both handle('start') catch AND handle('end') "no build id" branch invoke + // runDoctorOnFailure — the _doctorRanOnFailure guard must prevent the + // second call from reaching runDiagnostics. + expect(runDiagnostics).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/monitoring/src/index.js b/packages/monitoring/src/index.js index 3c41cfd4d..12ec756c2 100644 --- a/packages/monitoring/src/index.js +++ b/packages/monitoring/src/index.js @@ -58,9 +58,26 @@ export default class Monitoring { this.log.debug(`[Disk] Available Space: ${diskSpace}`); this.log.debug(`[Memory] Total: ${mem.total / (1024 ** 3)} gb, Swap Space: ${mem.swaptotal / (1024 ** 3)} gb`); this.log.debug(`Container Level: ${this.isContainer}, Pod Level: ${this.isPod}, Machine Level: ${this.isMachine}`); - this.log.debug(`Percy Envs: ${JSON.stringify(percyEnvs)}`); + this.log.debug(`Percy Envs: ${Object.keys(percyEnvs).join(', ')}`); + + return { + os: { platform: this.os, type: os.type(), release: os.release() }, + cpu: { name: cpuName, arch: cpu.arch, cores: cpu.cores }, + disk: { available: diskSpace }, + memory: { + totalGb: mem.total / (1024 ** 3), + swapGb: mem.swaptotal / (1024 ** 3) + }, + containerInfo: { + isContainer: this.isContainer, + isPod: this.isPod, + isMachine: this.isMachine + }, + percyEnvs + }; } catch (error) { this.log.debug(`Error logging system info: ${error}`); + return null; } }