From a0bce18151054f004e481a1d441ab11ca5291f7f Mon Sep 17 00:00:00 2001 From: shantanuk-browserstack <131665162+shantanuk-browserstack@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:22:54 +0530 Subject: [PATCH 1/4] feat(client): add visual-config support for create-build (#2139) --- packages/client/src/client.js | 131 ++++++++++++++++++++++++++- packages/client/test/client.test.js | 135 ++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 119958988..452c60e93 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -54,6 +54,133 @@ function makeRegions(regions, algorithm, algorithmConfiguration) { })); } +const VISUAL_CONFIG_TOP_LEVEL_KEYS = new Set([ + 'enableLayout', + 'percyCssValue', + 'compareWithPreviousRun', + 'diffIgnoreEnabled', + 'diffIgnorePercentage', + 'diffSensitivity', + 'browsers', + 'intelliIgnore' +]); + +const VISUAL_CONFIG_INTELLI_IGNORE_KEYS = new Set([ + 'enabled', + 'dynamic', + 'ignoreAds', + 'ignoreBanners', + 'ignoreCarousels', + 'ignoreCustomElementsEnabled', + 'ignoreCustomElementsClasses', + 'ignoreImages', + 'diffIgnorePercentage' +]); + +function validateBoolean(value, path) { + if (value != null && typeof value !== 'boolean') { + throw new Error(`Invalid PERCY_VISUAL_CONFIG: '${path}' must be a boolean`); + } +} + +function validateNumberInRange(value, path) { + if (value == null) return; + if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 1) { + throw new Error(`Invalid PERCY_VISUAL_CONFIG: '${path}' must be a number between 0 and 1`); + } +} + +function validateIntegerRange(value, path, min, max) { + if (value == null) return; + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error( + `Invalid PERCY_VISUAL_CONFIG: '${path}' must be an integer between ${min} and ${max}` + ); + } +} + +function parseVisualConfigFromEnv(log) { + let rawVisualConfig = process.env.PERCY_VISUAL_CONFIG; + if (!rawVisualConfig) return; + + let parsed; + try { + parsed = JSON.parse(rawVisualConfig); + } catch { + throw new Error('Invalid PERCY_VISUAL_CONFIG: value must be valid JSON'); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Invalid PERCY_VISUAL_CONFIG: value must be a JSON object'); + } + + let visualConfig = {}; + for (let key of Object.keys(parsed)) { + if (!VISUAL_CONFIG_TOP_LEVEL_KEYS.has(key)) { + log.warn(`Ignoring unknown PERCY_VISUAL_CONFIG key: '${key}'`); + continue; + } + visualConfig[key] = parsed[key]; + } + + validateBoolean(visualConfig.enableLayout, 'enableLayout'); + if (visualConfig.percyCssValue != null && typeof visualConfig.percyCssValue !== 'string') { + throw new Error("Invalid PERCY_VISUAL_CONFIG: 'percyCssValue' must be a string"); + } + validateBoolean(visualConfig.compareWithPreviousRun, 'compareWithPreviousRun'); + validateBoolean(visualConfig.diffIgnoreEnabled, 'diffIgnoreEnabled'); + validateNumberInRange(visualConfig.diffIgnorePercentage, 'diffIgnorePercentage'); + validateIntegerRange(visualConfig.diffSensitivity, 'diffSensitivity', 1, 5); + + if (visualConfig.browsers != null) { + if (!Array.isArray(visualConfig.browsers) || !visualConfig.browsers.every(b => typeof b === 'string')) { + throw new Error("Invalid PERCY_VISUAL_CONFIG: 'browsers' must be an array of strings"); + } + visualConfig.browsers = normalizeBrowsers(visualConfig.browsers); + } + + if (visualConfig.intelliIgnore != null) { + if (!visualConfig.intelliIgnore || typeof visualConfig.intelliIgnore !== 'object' || + Array.isArray(visualConfig.intelliIgnore)) { + throw new Error("Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore' must be an object"); + } + + let sanitizedIntelliIgnore = {}; + for (let key of Object.keys(visualConfig.intelliIgnore)) { + if (!VISUAL_CONFIG_INTELLI_IGNORE_KEYS.has(key)) { + log.warn(`Ignoring unknown PERCY_VISUAL_CONFIG intelliIgnore key: '${key}'`); + continue; + } + sanitizedIntelliIgnore[key] = visualConfig.intelliIgnore[key]; + } + + validateBoolean(sanitizedIntelliIgnore.enabled, 'intelliIgnore.enabled'); + validateBoolean(sanitizedIntelliIgnore.dynamic, 'intelliIgnore.dynamic'); + validateBoolean(sanitizedIntelliIgnore.ignoreAds, 'intelliIgnore.ignoreAds'); + validateBoolean(sanitizedIntelliIgnore.ignoreBanners, 'intelliIgnore.ignoreBanners'); + validateBoolean(sanitizedIntelliIgnore.ignoreCarousels, 'intelliIgnore.ignoreCarousels'); + validateBoolean( + sanitizedIntelliIgnore.ignoreCustomElementsEnabled, + 'intelliIgnore.ignoreCustomElementsEnabled' + ); + if (sanitizedIntelliIgnore.ignoreCustomElementsClasses != null && + typeof sanitizedIntelliIgnore.ignoreCustomElementsClasses !== 'string') { + throw new Error( + "Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore.ignoreCustomElementsClasses' must be a string" + ); + } + validateBoolean(sanitizedIntelliIgnore.ignoreImages, 'intelliIgnore.ignoreImages'); + validateNumberInRange( + sanitizedIntelliIgnore.diffIgnorePercentage, + 'intelliIgnore.diffIgnorePercentage' + ); + + visualConfig.intelliIgnore = sanitizedIntelliIgnore; + } + + return visualConfig; +} + // Validate project path arguments function validateProjectPath(path) { if (!path) throw new Error('Missing project path'); @@ -188,6 +315,7 @@ export class PercyClient { // done more seamlessly without manually tracking build ids async createBuild({ resources = [], projectType, cliStartTime = null } = {}) { this.log.debug('Creating a new build...'); + let visualConfig = parseVisualConfigFromEnv(this.log); let source = 'user_created'; if (process.env.PERCY_ORIGINATED_SOURCE) { @@ -222,7 +350,8 @@ export class PercyClient { source: source, 'skip-base-build': this.config.percy?.skipBaseBuild, 'testhub-build-uuid': this.env.testhubBuildUuid, - 'testhub-build-run-id': this.env.testhubBuildRunId + 'testhub-build-run-id': this.env.testhubBuildRunId, + ...(visualConfig ? { 'visual-config': visualConfig } : {}) }, relationships: { resources: { diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 67f1df5b7..6227423ae 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -198,6 +198,7 @@ describe('PercyClient', () => { beforeEach(() => { delete process.env.PERCY_AUTO_ENABLED_GROUP_BUILD; delete process.env.PERCY_ORIGINATED_SOURCE; + delete process.env.PERCY_VISUAL_CONFIG; }); it('creates a new build', async () => { @@ -604,6 +605,140 @@ describe('PercyClient', () => { } })); }); + + it('creates a new build with visual-config from PERCY_VISUAL_CONFIG', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ + diffSensitivity: 3, + compareWithPreviousRun: false, + intelliIgnore: { + enabled: true, + dynamic: true, + ignoreCustomElementsClasses: '.ad;.promo' + } + }); + + await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes['visual-config']) + .toEqual({ + diffSensitivity: 3, + compareWithPreviousRun: false, + intelliIgnore: { + enabled: true, + dynamic: true, + ignoreCustomElementsClasses: '.ad;.promo' + } + }); + }); + + it('warns and strips unknown visual-config keys before build creation', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ + diffSensitivity: 2, + unknownTopLevel: true, + intelliIgnore: { + enabled: true, + unknownNested: true + } + }); + + await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved(); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + "[percy:client] Ignoring unknown PERCY_VISUAL_CONFIG key: 'unknownTopLevel'", + "[percy:client] Ignoring unknown PERCY_VISUAL_CONFIG intelliIgnore key: 'unknownNested'" + ])); + expect(api.requests['/builds'][0].body.data.attributes['visual-config']) + .toEqual({ + diffSensitivity: 2, + intelliIgnore: { + enabled: true + } + }); + }); + + it('throws when PERCY_VISUAL_CONFIG is invalid JSON', async () => { + process.env.PERCY_VISUAL_CONFIG = '{ invalid json }'; + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError('Invalid PERCY_VISUAL_CONFIG: value must be valid JSON'); + }); + + it('throws when PERCY_VISUAL_CONFIG contains invalid types', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ + diffSensitivity: 'high' + }); + + await expectAsync(client.createBuild({ projectType: 'web' })).toBeRejectedWithError( + "Invalid PERCY_VISUAL_CONFIG: 'diffSensitivity' must be an integer between 1 and 5" + ); + }); + + it('throws when PERCY_VISUAL_CONFIG is not a JSON object', async () => { + process.env.PERCY_VISUAL_CONFIG = '"just a string"'; + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError('Invalid PERCY_VISUAL_CONFIG: value must be a JSON object'); + }); + + it('throws when PERCY_VISUAL_CONFIG boolean field has non-boolean value', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ enableLayout: 'yes' }); + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'enableLayout' must be a boolean"); + }); + + it('throws when PERCY_VISUAL_CONFIG percyCssValue is not a string', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ percyCssValue: 123 }); + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'percyCssValue' must be a string"); + }); + + it('throws when PERCY_VISUAL_CONFIG diffIgnorePercentage is out of range', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ diffIgnorePercentage: 5 }); + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'diffIgnorePercentage' must be a number between 0 and 1"); + }); + + it('creates a new build with valid diffIgnorePercentage', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ diffIgnorePercentage: 0.5 }); + + await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes['visual-config']) + .toEqual(jasmine.objectContaining({ diffIgnorePercentage: 0.5 })); + }); + + it('throws when PERCY_VISUAL_CONFIG browsers is not an array of strings', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ browsers: 'chrome' }); + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'browsers' must be an array of strings"); + }); + + it('creates a new build with visual-config browsers array', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ browsers: ['chrome', 'firefox'] }); + + await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes['visual-config']) + .toEqual(jasmine.objectContaining({ browsers: jasmine.any(Array) })); + }); + + it('throws when PERCY_VISUAL_CONFIG intelliIgnore is not an object', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ intelliIgnore: [] }); + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore' must be an object"); + }); + + it('throws when PERCY_VISUAL_CONFIG intelliIgnore.ignoreCustomElementsClasses is not a string', async () => { + process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ intelliIgnore: { ignoreCustomElementsClasses: 123 } }); + + await expectAsync(client.createBuild({ projectType: 'web' })) + .toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore.ignoreCustomElementsClasses' must be a string"); + }); }); describe('#getBuild()', () => { From 4fb6a91bbc295d6c64bb23ba31d5775b4f82c288 Mon Sep 17 00:00:00 2001 From: Manoj Katta <134484519+Manoj-Katta@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:17:48 +0530 Subject: [PATCH 2/4] fix(core): add pseudoClassEnabledElements to TypeScript types (PPLT-5013) (#2204) The runtime config schema accepted `pseudoClassEnabledElements`, but the TypeScript definition in @percy/core did not include it, forcing TS users to suppress type errors when passing the option to percySnapshot. Adding it to CommonSnapshotOptions fixes the type for every SDK that imports SnapshotOptions from @percy/core (ember, puppeteer, playwright, selenium, webdriverio, etc.). Co-authored-by: Claude Opus 4.7 (1M context) --- packages/core/types/index.d.ts | 8 ++++++++ packages/core/types/index.test-d.ts | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 0c87f7be0..69ad69e06 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -28,6 +28,13 @@ interface ScopeOptions { scroll?: boolean; } +interface PseudoClassEnabledElements { + id?: string[]; + className?: string[]; + xpath?: string[]; + selectors?: string[]; +} + interface DiscoveryLaunchOptions { executable?: string; args?: string[]; @@ -59,6 +66,7 @@ interface CommonSnapshotOptions { devicePixelRatio?: number; scopeOptions?: ScopeOptions; browsers?: string[]; + pseudoClassEnabledElements?: PseudoClassEnabledElements; } // Region support for TypeScript interface BoundingBox { diff --git a/packages/core/types/index.test-d.ts b/packages/core/types/index.test-d.ts index 2e1ad7930..d8f6b35bd 100644 --- a/packages/core/types/index.test-d.ts +++ b/packages/core/types/index.test-d.ts @@ -368,3 +368,27 @@ expectType>(percy.snapshot({ name: 'Snapshot', regions: [region, regionWithPadding, regionFull] })); + +// pseudoClassEnabledElements +expectType>(percy.snapshot({ + url: 'http://localhost:3000', + name: 'pseudo-class snapshot', + pseudoClassEnabledElements: { + selectors: [':popover-open'], + id: ['my-id'], + className: ['my-class'], + xpath: ['//div[@id="x"]'] + } +})); + +expectType(percy.setConfig({ + snapshot: { + pseudoClassEnabledElements: { selectors: [':popover-open'] } + } +})); + +expectError(percy.snapshot({ + url: 'http://localhost:3000', + name: 'invalid pseudo-class snapshot', + pseudoClassEnabledElements: { unknown: ['x'] } +})); From 4ed5ac072cae8d843b691c6ee2b3a7f855a7b802 Mon Sep 17 00:00:00 2001 From: Pranav Z Date: Wed, 29 Apr 2026 15:22:43 +0530 Subject: [PATCH 3/4] feat(env): auto-detect 10 additional CI providers (PER-7828) (#2194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(env): auto-detect 10 additional CI providers (PER-7828) Add detection plus commit/branch/PR/parallel-nonce extraction for: TeamCity, AWS CodeBuild, Google Cloud Build, Atlassian Bamboo, Bitrise, Codemagic, Vercel, Cloudflare Pages, GoCD, Woodpecker. Detection ordering: - Woodpecker placed before Drone (guards against pre-3.x Woodpecker installs that set DRONE=true for backwards compatibility). - GCB placed last before the CI/unknown fallback because BUILD_ID + PROJECT_ID is the most generic marker; defensive !JENKINS_URL guard added. Parallel-nonce rerun-stability choices: - Bamboo uses bamboo_buildResultKey (includes build-N suffix) rather than bamboo_buildNumber (reused on rerun). - Cloudflare Pages uses a composite \${commit}-\${url} nonce with a strict null-guard on commit SHA so we never emit "undefined". - GoCD uses a composite \${pipeline}.\${stage} counter so stage reruns do not collide. Each provider gets a dedicated test file covering detection, PR builds (where applicable), edge cases (CodeBuild manual triggers, GCB manual submits, Vercel system-env-vars-off, Woodpecker Drone-compat collision, Jenkins-over-GCB precedence), and PERCY_* override precedence. API side is a no-op: the 'source' field is free-form metadata with no allowlist. Tekton/Argo excluded — no standard git env vars. Documentation for the 10 new providers (plus doc gaps for Harness, Heroku CI, Probo.CI) ships in a follow-up PR. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(env): fix CF Pages nonce + add Tekton/Argo detection E2E testing revealed the Cloudflare Pages composite nonce (CF_PAGES_COMMIT_SHA-CF_PAGES_URL) exceeded Percy's 64-char API limit, causing build creation to fail. Switched to CF_PAGES_COMMIT_SHA alone — this also gives correct rerun dedup behavior since the URL changes per redeploy. Added opt-in detection for Tekton Pipelines and Argo Workflows. Neither auto-injects identifying env vars into step containers, so users set them via template substitution: # Tekton env: - name: TEKTON_PIPELINE_RUN value: "$(context.pipelineRun.name)" - name: TEKTON_COMMIT_SHA value: "$(params.commit-sha)" - name: TEKTON_BRANCH value: "$(params.branch)" # Argo Workflows env: - name: ARGO_WORKFLOW_NAME value: "{{workflow.name}}" - name: ARGO_WORKFLOW_UID value: "{{workflow.uid}}" - name: ARGO_COMMIT_SHA value: "{{workflow.parameters.commit-sha}}" - name: ARGO_BRANCH value: "{{workflow.parameters.branch}}" šŸ¤– Generated with Claude Opus 4.7 (1M context) via Claude Code + Compound Engineering v2.50.0 Co-Authored-By: Claude Opus 4.7 (1M context) * docs(env): list all 12 new CI providers + opt-in setup Adds README entries for the 10 auto-detect providers (TeamCity, AWS CodeBuild, GCB, Bamboo, Bitrise, Codemagic, Vercel, Cloudflare Pages, GoCD, Woodpecker), backfills Harness CI (was detected in code but missing from the list), and documents the opt-in setup for Tekton Pipelines and Argo Workflows with copy-paste YAML snippets. Also calls out the Vercel System Env Vars + PERCY_PARALLEL_TOTAL=-1 requirement surfaced during E2E testing. šŸ¤– Generated with Claude Opus 4.7 (1M context) via Claude Code + Compound Engineering v2.50.0 Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- packages/env/README.md | 64 ++++++++++++ packages/env/src/environment.js | 112 +++++++++++++++++++++ packages/env/test/argo-workflows.test.js | 54 ++++++++++ packages/env/test/aws-codebuild.test.js | 64 ++++++++++++ packages/env/test/bamboo.test.js | 46 +++++++++ packages/env/test/bitrise.test.js | 43 ++++++++ packages/env/test/cloudflare-pages.test.js | 49 +++++++++ packages/env/test/codemagic.test.js | 55 ++++++++++ packages/env/test/gcb.test.js | 63 ++++++++++++ packages/env/test/gocd.test.js | 61 +++++++++++ packages/env/test/teamcity.test.js | 47 +++++++++ packages/env/test/tekton.test.js | 48 +++++++++ packages/env/test/vercel.test.js | 48 +++++++++ packages/env/test/woodpecker.test.js | 67 ++++++++++++ 14 files changed, 821 insertions(+) create mode 100644 packages/env/test/argo-workflows.test.js create mode 100644 packages/env/test/aws-codebuild.test.js create mode 100644 packages/env/test/bamboo.test.js create mode 100644 packages/env/test/bitrise.test.js create mode 100644 packages/env/test/cloudflare-pages.test.js create mode 100644 packages/env/test/codemagic.test.js create mode 100644 packages/env/test/gcb.test.js create mode 100644 packages/env/test/gocd.test.js create mode 100644 packages/env/test/teamcity.test.js create mode 100644 packages/env/test/tekton.test.js create mode 100644 packages/env/test/vercel.test.js create mode 100644 packages/env/test/woodpecker.test.js diff --git a/packages/env/README.md b/packages/env/README.md index e6f934996..fc8f27e0c 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -5,22 +5,86 @@ into a common interface for consumption by `@percy/client`. ## Supported Environments +Auto-detected based on environment variables that the CI provider sets during a build. + - [AppVeyor](https://www.browserstack.com/docs/percy/ci-cd/appveyor) +- [Atlassian Bamboo](#supported-environments) (needs doc) +- [AWS CodeBuild](#supported-environments) (needs doc) - [Azure Pipelines](https://www.browserstack.com/docs/percy/ci-cd/azure-pipelines) - [Bitbucket Pipelines](https://www.browserstack.com/docs/percy/ci-cd/bitbucket-pipeline) +- [Bitrise](#supported-environments) (needs doc) - [Buildkite](https://www.browserstack.com/docs/percy/ci-cd/buildkite) - [CircleCI](https://www.browserstack.com/docs/percy/ci-cd/circleci) +- [Cloudflare Pages](#supported-environments) (needs doc) +- [Codemagic](#supported-environments) (needs doc) - [Codeship](https://www.browserstack.com/docs/percy/ci-cd/codeship) - [Drone CI](https://docs.percy.io/docs/drone) - [GitHub Actions](https://www.browserstack.com/docs/percy/ci-cd/github-actions) - [GitLab CI](https://www.browserstack.com/docs/percy/ci-cd/gitlab) +- [GoCD](#supported-environments) (needs doc) +- [Google Cloud Build](#supported-environments) (needs doc) +- [Harness CI](#supported-environments) (needs doc) - [Heroku CI](#supported-environments) (needs doc) - [Jenkins](https://www.browserstack.com/docs/percy/ci-cd/jenkins) - [Jenkins PRB](https://www.browserstack.com/docs/percy/ci-cd/jenkins) - [Netlify](https://www.browserstack.com/docs/percy/ci-cd/netlify) - [Probo.CI](#supported-environments) (needs doc) - [Semaphore](https://www.browserstack.com/docs/percy/ci-cd/semaphore) +- [TeamCity](#supported-environments) (needs doc) - [Travis CI](https://www.browserstack.com/docs/percy/ci-cd/travis-ci) +- [Vercel](#vercel) — see note below +- [Woodpecker CI](#supported-environments) (needs doc) + +## Opt-in Environments + +Kubernetes-native pipelines do not inject provider-identifying environment variables +into step containers by default. To enable Percy detection on these systems, expose +the following variables via template substitution in your pipeline definition. + +### Tekton Pipelines + +```yaml +steps: + - name: percy + image: node:20 + env: + - name: TEKTON_PIPELINE_RUN # required — triggers detection + value: "$(context.pipelineRun.name)" + - name: TEKTON_COMMIT_SHA + value: "$(params.commit-sha)" + - name: TEKTON_BRANCH + value: "$(params.branch)" + - name: TEKTON_PULL_REQUEST # optional + value: "$(params.pr-number)" +``` + +### Argo Workflows + +```yaml +- name: percy + container: + image: node:20 + env: + - name: ARGO_WORKFLOW_NAME # required — triggers detection + value: "{{workflow.name}}" + - name: ARGO_WORKFLOW_UID # recommended — used as parallel nonce + value: "{{workflow.uid}}" + - name: ARGO_COMMIT_SHA + value: "{{workflow.parameters.commit-sha}}" + - name: ARGO_BRANCH + value: "{{workflow.parameters.branch}}" + - name: ARGO_PULL_REQUEST # optional + value: "{{workflow.parameters.pr-number}}" +``` + +### Vercel + +Vercel exposes its `VERCEL_*` system environment variables to the build step only +when **Automatically expose System Environment Variables** is enabled on the project +(Settings → Environment Variables). Percy also needs `PERCY_PARALLEL_TOTAL=-1` +set in the project environment for the parallel nonce to populate from +`VERCEL_DEPLOYMENT_ID` — otherwise reruns of the same deploy will create separate +Percy builds instead of deduping. ## Percy Environment Variables diff --git a/packages/env/src/environment.js b/packages/env/src/environment.js index 859169b87..222b51d35 100644 --- a/packages/env/src/environment.js +++ b/packages/env/src/environment.js @@ -23,6 +23,8 @@ export class PercyEnv { return 'circle'; } else if (this.vars.CI_NAME === 'codeship') { return 'codeship'; + } else if (this.vars.CI_SYSTEM_NAME === 'woodpecker' || this.vars.CI === 'woodpecker') { + return 'woodpecker'; } else if (this.vars.DRONE === 'true') { return 'drone'; } else if (this.vars.SEMAPHORE === 'true') { @@ -47,6 +49,28 @@ export class PercyEnv { return 'netlify'; } else if (this.vars.HARNESS_PROJECT_ID) { return 'harness'; + } else if (this.vars.TEAMCITY_VERSION) { + return 'teamcity'; + } else if (this.vars.CODEBUILD_BUILD_ID) { + return 'aws-codebuild'; + } else if (this.vars.bamboo_buildKey) { + return 'bamboo'; + } else if (this.vars.BITRISE_IO === 'true') { + return 'bitrise'; + } else if (this.vars.CM_BUILD_ID) { + return 'codemagic'; + } else if (this.vars.VERCEL === '1') { + return 'vercel'; + } else if (this.vars.CF_PAGES === '1') { + return 'cloudflare-pages'; + } else if (this.vars.GO_PIPELINE_NAME && this.vars.GO_SERVER_URL) { + return 'gocd'; + } else if (this.vars.BUILD_ID && this.vars.PROJECT_ID && !this.vars.JENKINS_URL) { + return 'gcb'; + } else if (this.vars.TEKTON_PIPELINE_RUN) { + return 'tekton'; + } else if (this.vars.ARGO_WORKFLOW_NAME) { + return 'argo-workflows'; } else if (this.vars.CI) { return 'CI/unknown'; } else { @@ -109,6 +133,30 @@ export class PercyEnv { return github(this.vars).pull_request?.head.sha || this.vars.GITHUB_SHA; case 'harness': return this.vars.DRONE_COMMIT_SHA; + case 'woodpecker': + return this.vars.CI_COMMIT_SHA; + case 'teamcity': + return this.vars.BUILD_VCS_NUMBER; + case 'aws-codebuild': + return this.vars.CODEBUILD_RESOLVED_SOURCE_VERSION; + case 'bamboo': + return this.vars.bamboo_planRepository_revision; + case 'bitrise': + return this.vars.BITRISE_GIT_COMMIT; + case 'codemagic': + return this.vars.CM_COMMIT; + case 'vercel': + return this.vars.VERCEL_GIT_COMMIT_SHA; + case 'cloudflare-pages': + return this.vars.CF_PAGES_COMMIT_SHA; + case 'gocd': + return this.vars.GO_REVISION; + case 'gcb': + return this.vars.COMMIT_SHA; + case 'tekton': + return this.vars.TEKTON_COMMIT_SHA; + case 'argo-workflows': + return this.vars.ARGO_COMMIT_SHA; } })(); @@ -157,6 +205,26 @@ export class PercyEnv { return this.vars.HEAD; case 'harness': return this.vars.DRONE_SOURCE_BRANCH || this.vars.DRONE_COMMIT_BRANCH; + case 'woodpecker': + return this.vars.CI_COMMIT_BRANCH; + case 'aws-codebuild': + return this.vars.CODEBUILD_WEBHOOK_HEAD_REF; + case 'bamboo': + return this.vars.bamboo_planRepository_branchName; + case 'bitrise': + return this.vars.BITRISE_GIT_BRANCH; + case 'codemagic': + return this.vars.CM_BRANCH; + case 'vercel': + return this.vars.VERCEL_GIT_COMMIT_REF; + case 'cloudflare-pages': + return this.vars.CF_PAGES_BRANCH; + case 'gcb': + return this.vars.BRANCH_NAME; + case 'tekton': + return this.vars.TEKTON_BRANCH; + case 'argo-workflows': + return this.vars.ARGO_BRANCH; } })(); @@ -203,6 +271,24 @@ export class PercyEnv { return github(this.vars).pull_request?.number; case 'harness': return this.vars.DRONE_BUILD_EVENT === 'pull_request' && this.vars.DRONE_COMMIT_LINK?.split('/').slice(-1)[0]; + case 'woodpecker': + return this.vars.CI_PIPELINE_EVENT === 'pull_request' && this.vars.CI_COMMIT_PULL_REQUEST; + case 'aws-codebuild': + return this.vars.CODEBUILD_WEBHOOK_TRIGGER?.match(/^pr\/(\d+)$/)?.[1]; + case 'bamboo': + return this.vars.bamboo_repository_pr_key; + case 'bitrise': + return this.vars.BITRISE_PULL_REQUEST; + case 'codemagic': + return this.vars.CM_PULL_REQUEST === 'true' && this.vars.CM_PULL_REQUEST_NUMBER; + case 'vercel': + return this.vars.VERCEL_GIT_PULL_REQUEST_ID; + case 'gcb': + return this.vars._PR_NUMBER; + case 'tekton': + return this.vars.TEKTON_PULL_REQUEST; + case 'argo-workflows': + return this.vars.ARGO_PULL_REQUEST; } })(); @@ -261,6 +347,32 @@ export class PercyEnv { return this.vars.GITHUB_RUN_ID; case 'harness': return this.vars.HARNESS_BUILD_ID; + case 'woodpecker': + return this.vars.CI_PIPELINE_NUMBER; + case 'teamcity': + return this.vars.BUILD_NUMBER; + case 'aws-codebuild': + return this.vars.CODEBUILD_BUILD_ID; + case 'bamboo': + return this.vars.bamboo_buildResultKey; + case 'bitrise': + return this.vars.BITRISE_BUILD_NUMBER; + case 'codemagic': + return this.vars.CM_BUILD_ID; + case 'vercel': + return this.vars.VERCEL_DEPLOYMENT_ID; + case 'cloudflare-pages': + return this.vars.CF_PAGES_COMMIT_SHA || null; + case 'gocd': + return this.vars.GO_PIPELINE_COUNTER && this.vars.GO_STAGE_COUNTER + ? `${this.vars.GO_PIPELINE_COUNTER}.${this.vars.GO_STAGE_COUNTER}` + : this.vars.GO_PIPELINE_COUNTER; + case 'gcb': + return this.vars.BUILD_ID; + case 'tekton': + return this.vars.TEKTON_PIPELINE_RUN; + case 'argo-workflows': + return this.vars.ARGO_WORKFLOW_UID || this.vars.ARGO_WORKFLOW_NAME; } })(); diff --git a/packages/env/test/argo-workflows.test.js b/packages/env/test/argo-workflows.test.js new file mode 100644 index 000000000..a77ece860 --- /dev/null +++ b/packages/env/test/argo-workflows.test.js @@ -0,0 +1,54 @@ +import PercyEnv from '@percy/env'; + +describe('Argo Workflows', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + ARGO_WORKFLOW_NAME: 'my-workflow-42', + ARGO_WORKFLOW_UID: 'argo-uid-xyz', + ARGO_COMMIT_SHA: 'argo-commit-sha', + ARGO_BRANCH: 'argo-branch' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'argo-workflows'); + expect(env).toHaveProperty('commit', 'argo-commit-sha'); + expect(env).toHaveProperty('branch', 'argo-branch'); + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'argo-uid-xyz'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR triggers', () => { + env = new PercyEnv({ ...env.vars, ARGO_PULL_REQUEST: '42' }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('falls back to workflow name when UID is absent', () => { + env = new PercyEnv({ ...env.vars, ARGO_WORKFLOW_UID: undefined }); + expect(env).toHaveProperty('parallel.nonce', 'my-workflow-42'); + }); + + it('is not detected when ARGO_WORKFLOW_NAME is unset (opt-in)', () => { + env = new PercyEnv({ + ARGO_WORKFLOW_UID: 'argo-uid-xyz', + ARGO_COMMIT_SHA: 'argo-commit-sha' + }); + expect(env).toHaveProperty('ci', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/aws-codebuild.test.js b/packages/env/test/aws-codebuild.test.js new file mode 100644 index 000000000..d4c813840 --- /dev/null +++ b/packages/env/test/aws-codebuild.test.js @@ -0,0 +1,64 @@ +import PercyEnv from '@percy/env'; + +describe('AWS CodeBuild', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + CODEBUILD_BUILD_ID: 'codebuild:build-id', + CODEBUILD_RESOLVED_SOURCE_VERSION: 'codebuild-commit-sha', + CODEBUILD_WEBHOOK_HEAD_REF: 'refs/heads/codebuild-branch', + CODEBUILD_WEBHOOK_TRIGGER: 'branch/codebuild-branch' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'aws-codebuild'); + expect(env).toHaveProperty('commit', 'codebuild-commit-sha'); + expect(env).toHaveProperty('branch', 'codebuild-branch'); + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'codebuild:build-id'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('parses pull-request number from CODEBUILD_WEBHOOK_TRIGGER', () => { + env = new PercyEnv({ + ...env.vars, + CODEBUILD_WEBHOOK_TRIGGER: 'pr/42' + }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('does not misattribute tag triggers as pull requests', () => { + env = new PercyEnv({ + ...env.vars, + CODEBUILD_WEBHOOK_TRIGGER: 'tag/v1.0.0' + }); + expect(env).toHaveProperty('pullRequest', null); + }); + + it('returns null for branch and PR on manual or EventBridge triggers (no webhook vars)', () => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + CODEBUILD_BUILD_ID: 'codebuild:build-id', + CODEBUILD_RESOLVED_SOURCE_VERSION: 'codebuild-commit-sha' + }); + expect(env).toHaveProperty('ci', 'aws-codebuild'); + expect(env).toHaveProperty('commit', 'codebuild-commit-sha'); + expect(env).toHaveProperty('branch', null); + expect(env).toHaveProperty('pullRequest', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/bamboo.test.js b/packages/env/test/bamboo.test.js new file mode 100644 index 000000000..8afacb551 --- /dev/null +++ b/packages/env/test/bamboo.test.js @@ -0,0 +1,46 @@ +import PercyEnv from '@percy/env'; + +describe('Bamboo', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + bamboo_buildKey: 'PROJ-PLAN-JOB', + bamboo_planRepository_revision: 'bamboo-commit-sha', + bamboo_planRepository_branchName: 'bamboo-branch', + bamboo_buildResultKey: 'PROJ-PLAN-JOB-42', + bamboo_buildNumber: '42' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'bamboo'); + expect(env).toHaveProperty('commit', 'bamboo-commit-sha'); + expect(env).toHaveProperty('branch', 'bamboo-branch'); + expect(env).toHaveProperty('pullRequest', null); + // buildResultKey (not buildNumber) so reruns don't collide + expect(env).toHaveProperty('parallel.nonce', 'PROJ-PLAN-JOB-42'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR builds', () => { + env = new PercyEnv({ + ...env.vars, + bamboo_repository_pr_key: '7' + }); + expect(env).toHaveProperty('pullRequest', '7'); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/bitrise.test.js b/packages/env/test/bitrise.test.js new file mode 100644 index 000000000..10cdf227e --- /dev/null +++ b/packages/env/test/bitrise.test.js @@ -0,0 +1,43 @@ +import PercyEnv from '@percy/env'; + +describe('Bitrise', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + BITRISE_IO: 'true', + BITRISE_GIT_COMMIT: 'bitrise-commit-sha', + BITRISE_GIT_BRANCH: 'bitrise-branch', + BITRISE_PULL_REQUEST: '', + BITRISE_BUILD_NUMBER: 'bitrise-build-number' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'bitrise'); + expect(env).toHaveProperty('commit', 'bitrise-commit-sha'); + expect(env).toHaveProperty('branch', 'bitrise-branch'); + // Bitrise sets empty string on non-PR builds + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'bitrise-build-number'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR builds', () => { + env = new PercyEnv({ ...env.vars, BITRISE_PULL_REQUEST: '42' }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/cloudflare-pages.test.js b/packages/env/test/cloudflare-pages.test.js new file mode 100644 index 000000000..996e4b4f7 --- /dev/null +++ b/packages/env/test/cloudflare-pages.test.js @@ -0,0 +1,49 @@ +import PercyEnv from '@percy/env'; + +describe('Cloudflare Pages', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + CF_PAGES: '1', + CF_PAGES_COMMIT_SHA: 'cf-commit-sha', + CF_PAGES_BRANCH: 'cf-branch', + CF_PAGES_URL: 'https://abc123.my-project.pages.dev' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'cloudflare-pages'); + expect(env).toHaveProperty('commit', 'cf-commit-sha'); + expect(env).toHaveProperty('branch', 'cf-branch'); + // Cloudflare Pages does not natively expose PR info + expect(env).toHaveProperty('pullRequest', null); + // Nonce is commit SHA alone — earlier composite (commit + URL) exceeded + // Percy's 64-char nonce limit and caused build creation to fail. + expect(env).toHaveProperty('parallel.nonce', 'cf-commit-sha'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('returns null nonce when CF_PAGES_COMMIT_SHA is absent (never emits "undefined")', () => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + CF_PAGES: '1', + CF_PAGES_BRANCH: 'cf-branch', + CF_PAGES_URL: 'https://abc123.my-project.pages.dev' + }); + expect(env).toHaveProperty('parallel.nonce', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/codemagic.test.js b/packages/env/test/codemagic.test.js new file mode 100644 index 000000000..dac6da53a --- /dev/null +++ b/packages/env/test/codemagic.test.js @@ -0,0 +1,55 @@ +import PercyEnv from '@percy/env'; + +describe('Codemagic', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + CM_BUILD_ID: 'codemagic-build-uuid', + CM_COMMIT: 'codemagic-commit-sha', + CM_BRANCH: 'codemagic-branch', + CM_PULL_REQUEST: 'false' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'codemagic'); + expect(env).toHaveProperty('commit', 'codemagic-commit-sha'); + expect(env).toHaveProperty('branch', 'codemagic-branch'); + // CM_PULL_REQUEST === 'false' (string) means non-PR build + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'codemagic-build-uuid'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR builds', () => { + env = new PercyEnv({ + ...env.vars, + CM_PULL_REQUEST: 'true', + CM_PULL_REQUEST_NUMBER: '42' + }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('ignores CM_PULL_REQUEST_NUMBER when CM_PULL_REQUEST is the string "false"', () => { + env = new PercyEnv({ + ...env.vars, + CM_PULL_REQUEST: 'false', + CM_PULL_REQUEST_NUMBER: '42' + }); + expect(env).toHaveProperty('pullRequest', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/gcb.test.js b/packages/env/test/gcb.test.js new file mode 100644 index 000000000..53b53ae41 --- /dev/null +++ b/packages/env/test/gcb.test.js @@ -0,0 +1,63 @@ +import PercyEnv from '@percy/env'; + +describe('Google Cloud Build', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + BUILD_ID: 'gcb-build-id', + PROJECT_ID: 'my-gcp-project', + COMMIT_SHA: 'gcb-commit-sha', + BRANCH_NAME: 'gcb-branch' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'gcb'); + expect(env).toHaveProperty('commit', 'gcb-commit-sha'); + expect(env).toHaveProperty('branch', 'gcb-branch'); + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'gcb-build-id'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR triggers', () => { + env = new PercyEnv({ ...env.vars, _PR_NUMBER: '42' }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('returns null commit/branch/PR on manual gcloud submits (no trigger vars)', () => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + BUILD_ID: 'gcb-build-id', + PROJECT_ID: 'my-gcp-project' + }); + expect(env).toHaveProperty('ci', 'gcb'); + expect(env).toHaveProperty('commit', null); + expect(env).toHaveProperty('branch', null); + expect(env).toHaveProperty('pullRequest', null); + }); + + it('does not match when JENKINS_URL is also set (Jenkins wins)', () => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + JENKINS_URL: 'https://jenkins.example.com', + BUILD_ID: 'jenkins-build-id', + PROJECT_ID: 'my-gcp-project' + }); + expect(env).toHaveProperty('ci', 'jenkins'); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/gocd.test.js b/packages/env/test/gocd.test.js new file mode 100644 index 000000000..35cb8cc6b --- /dev/null +++ b/packages/env/test/gocd.test.js @@ -0,0 +1,61 @@ +import PercyEnv from '@percy/env'; + +describe('GoCD', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + GO_PIPELINE_NAME: 'my-pipeline', + GO_SERVER_URL: 'https://gocd.example.com', + GO_REVISION: 'gocd-commit-sha', + GO_PIPELINE_COUNTER: '42', + GO_STAGE_COUNTER: '1' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'gocd'); + expect(env).toHaveProperty('commit', 'gocd-commit-sha'); + // GoCD does not expose branch/PR via standard env vars + expect(env).toHaveProperty('branch', null); + expect(env).toHaveProperty('pullRequest', null); + // Composite pipeline.stage counter to survive stage reruns + expect(env).toHaveProperty('parallel.nonce', '42.1'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('nonce changes when the stage counter bumps on rerun', () => { + env = new PercyEnv({ ...env.vars, GO_STAGE_COUNTER: '2' }); + expect(env).toHaveProperty('parallel.nonce', '42.2'); + }); + + it('falls back to pipeline counter alone when stage counter is absent', () => { + env = new PercyEnv({ ...env.vars, GO_STAGE_COUNTER: undefined }); + expect(env).toHaveProperty('parallel.nonce', '42'); + }); + + it('returns null commit on multi-material pipelines (GO_REVISION unset)', () => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + GO_PIPELINE_NAME: 'my-pipeline', + GO_SERVER_URL: 'https://gocd.example.com', + GO_PIPELINE_COUNTER: '42', + GO_STAGE_COUNTER: '1' + }); + expect(env).toHaveProperty('ci', 'gocd'); + expect(env).toHaveProperty('commit', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/teamcity.test.js b/packages/env/test/teamcity.test.js new file mode 100644 index 000000000..028afe718 --- /dev/null +++ b/packages/env/test/teamcity.test.js @@ -0,0 +1,47 @@ +import PercyEnv from '@percy/env'; + +describe('TeamCity', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + TEAMCITY_VERSION: '2024.07', + BUILD_VCS_NUMBER: 'teamcity-commit-sha', + BUILD_NUMBER: 'teamcity-build-number' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'teamcity'); + expect(env).toHaveProperty('commit', 'teamcity-commit-sha'); + // TeamCity does not expose branch/PR via standard env vars + expect(env).toHaveProperty('branch', null); + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'teamcity-build-number'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('returns null commit when only the multi-root suffixed var is set', () => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + TEAMCITY_VERSION: '2024.07', + BUILD_VCS_NUMBER_Repo1: 'teamcity-multi-root-sha', + BUILD_NUMBER: 'teamcity-build-number' + }); + expect(env).toHaveProperty('ci', 'teamcity'); + expect(env).toHaveProperty('commit', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/tekton.test.js b/packages/env/test/tekton.test.js new file mode 100644 index 000000000..43d11800e --- /dev/null +++ b/packages/env/test/tekton.test.js @@ -0,0 +1,48 @@ +import PercyEnv from '@percy/env'; + +describe('Tekton Pipelines', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + TEKTON_PIPELINE_RUN: 'my-pipeline-run-42', + TEKTON_COMMIT_SHA: 'tekton-commit-sha', + TEKTON_BRANCH: 'tekton-branch' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'tekton'); + expect(env).toHaveProperty('commit', 'tekton-commit-sha'); + expect(env).toHaveProperty('branch', 'tekton-branch'); + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'my-pipeline-run-42'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR triggers', () => { + env = new PercyEnv({ ...env.vars, TEKTON_PULL_REQUEST: '42' }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('is not detected when TEKTON_PIPELINE_RUN is unset (opt-in)', () => { + env = new PercyEnv({ + TEKTON_COMMIT_SHA: 'tekton-commit-sha', + TEKTON_BRANCH: 'tekton-branch' + }); + expect(env).toHaveProperty('ci', null); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/vercel.test.js b/packages/env/test/vercel.test.js new file mode 100644 index 000000000..269c9fce7 --- /dev/null +++ b/packages/env/test/vercel.test.js @@ -0,0 +1,48 @@ +import PercyEnv from '@percy/env'; + +describe('Vercel', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + VERCEL: '1', + VERCEL_GIT_COMMIT_SHA: 'vercel-commit-sha', + VERCEL_GIT_COMMIT_REF: 'vercel-branch', + VERCEL_GIT_PULL_REQUEST_ID: '', + VERCEL_DEPLOYMENT_ID: 'dpl_vercel-deployment-id' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'vercel'); + expect(env).toHaveProperty('commit', 'vercel-commit-sha'); + expect(env).toHaveProperty('branch', 'vercel-branch'); + // Empty string when branch has no PR yet or on production deploys + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'dpl_vercel-deployment-id'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR builds', () => { + env = new PercyEnv({ ...env.vars, VERCEL_GIT_PULL_REQUEST_ID: '42' }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('falls through to CI/unknown when system env vars are not exposed (checkbox off)', () => { + env = new PercyEnv({ CI: 'true' }); + expect(env).toHaveProperty('ci', 'CI/unknown'); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); diff --git a/packages/env/test/woodpecker.test.js b/packages/env/test/woodpecker.test.js new file mode 100644 index 000000000..13b79d0e1 --- /dev/null +++ b/packages/env/test/woodpecker.test.js @@ -0,0 +1,67 @@ +import PercyEnv from '@percy/env'; + +describe('Woodpecker', () => { + let env; + + beforeEach(() => { + env = new PercyEnv({ + PERCY_PARALLEL_TOTAL: '-1', + CI_SYSTEM_NAME: 'woodpecker', + CI: 'woodpecker', + CI_COMMIT_SHA: 'woodpecker-commit-sha', + CI_COMMIT_BRANCH: 'woodpecker-branch', + CI_PIPELINE_NUMBER: 'woodpecker-pipeline-number' + }); + }); + + it('has the correct properties', () => { + expect(env).toHaveProperty('ci', 'woodpecker'); + expect(env).toHaveProperty('commit', 'woodpecker-commit-sha'); + expect(env).toHaveProperty('branch', 'woodpecker-branch'); + expect(env).toHaveProperty('target.commit', null); + expect(env).toHaveProperty('target.branch', null); + expect(env).toHaveProperty('pullRequest', null); + expect(env).toHaveProperty('parallel.nonce', 'woodpecker-pipeline-number'); + expect(env).toHaveProperty('parallel.total', -1); + }); + + it('has the correct properties for PR builds', () => { + env = new PercyEnv({ + ...env.vars, + CI_PIPELINE_EVENT: 'pull_request', + CI_COMMIT_PULL_REQUEST: '42' + }); + expect(env).toHaveProperty('pullRequest', '42'); + }); + + it('ignores CI_COMMIT_PULL_REQUEST on non-pull_request events', () => { + env = new PercyEnv({ + ...env.vars, + CI_PIPELINE_EVENT: 'push', + CI_COMMIT_PULL_REQUEST: '42' + }); + expect(env).toHaveProperty('pullRequest', null); + }); + + it('wins over Drone when Drone-compat vars are also set', () => { + env = new PercyEnv({ + ...env.vars, + DRONE: 'true', + DRONE_COMMIT: 'drone-commit' + }); + expect(env).toHaveProperty('ci', 'woodpecker'); + expect(env).toHaveProperty('commit', 'woodpecker-commit-sha'); + }); + + it('respects PERCY_* overrides', () => { + env = new PercyEnv({ + ...env.vars, + PERCY_COMMIT: 'override-commit', + PERCY_BRANCH: 'override-branch', + PERCY_PULL_REQUEST: '999' + }); + expect(env).toHaveProperty('commit', 'override-commit'); + expect(env).toHaveProperty('branch', 'override-branch'); + expect(env).toHaveProperty('pullRequest', '999'); + }); +}); From 3dce017c73d7b86645da106665fff40e636a9ff2 Mon Sep 17 00:00:00 2001 From: Pranav Zinzurde Date: Wed, 29 Apr 2026 16:36:12 +0530 Subject: [PATCH 4/4] PER-7809: disk-backed logger for bounded CLI memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces @percy/logger's unbounded `messages` Set with a JSONL-backed hybrid store that keeps resident memory bounded across long builds (10k snapshots, 8-hour deferUploads runs) while preserving byte-for-byte parity with master's `/logs` upload payload and per-snapshot log resources. Design ------ - writes go through a 500-entry / 100ms buffer flushed via fs.appendFileSync to ${tmpdir}/percy-logs//-.jsonl - snapshotLogs(meta) reads the disk delta into a bounded `cache` keyed by snapshot meta; evictSnapshot drops the cache entry once the snapshot's upload is complete; late entries are allowed to repopulate (retry-safe) - query(filter) streams the JSONL once per call (chunked 64KB read); in-memory mode preserves master's identity-mutation contract that redactSecrets relies on - disk-init failures, mid-build appendFileSync failures, and the PERCY_LOGS_IN_MEMORY=1 rollback all flip to an unbounded in-memory Set (master parity) — including replaying entries already on disk so the upload still includes them - circular meta is sanitized via JSON.stringify try/catch, but meta.snapshot is preserved so the entry still routes via snapshotLogs - exit cleanup uses a process-scoped Set on `process[Symbol.for(...)]` shared across module copies; supports multiple live instances and unlinks every active disk file on `exit` / `beforeExit` - per-pid subdir prevents concurrent percy processes (CI matrix, parallel workers, npx invocations) from clobbering each other's files; cleanup best-effort rmdirs the subdir so long-lived runners don't accumulate Wiring ------ - packages/core/src/discovery.js — uses snapshotLogs/evictSnapshot for per-snapshot log resources - packages/core/src/api.js — /test/logs and the test-mode reset path now use logger.query() / logger.instance.reset() - packages/core/test/helpers/index.js — defaults setupTest to memory mode (master parity) so downstream tests using mockfs don't flake against the disk path's live volume Tests ----- - 68 specs, 100% statements/branches/functions/lines on logger.js - the existing 37-case logger suite runs under the disk path by default (no PERCY_LOGS_IN_MEMORY set in helpers.mock); 25+ new specs in describe('disk-backed storage') cover round-trip, snapshotLogs / evict retry semantics, fallback after appendFileSync / mkdirSync failures, the 100ms timer, the 500-entry size cap, per-pid subdir, the Symbol.for latch, multi-instance cleanup, rmdir best-effort, and circular-meta snapshot preservation - cli-exec / cli-snapshot / cli-build / cli-doctor / cli-upload / cli-command / cli / core / config / client / env / monitoring / webdriver-utils all green locally on Node 14 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/api.js | 4 +- packages/core/src/discovery.js | 5 +- packages/core/test/api.test.js | 2 +- packages/core/test/percy.test.js | 2 +- packages/logger/src/index.js | 3 + packages/logger/src/logger.js | 464 ++++++++- packages/logger/test/helpers.js | 19 +- packages/logger/test/logger.test.js | 1389 +++++++++++++++++++-------- 8 files changed, 1416 insertions(+), 472 deletions(-) diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 9455b214b..dc40e415b 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -230,7 +230,7 @@ export function createPercyServer(percy, port) { if (cmd === 'reset') { // the reset command will reset testing mode and clear any logs percy.testing = {}; - logger.instance.messages.clear(); + logger.instance.reset(); } else if (cmd === 'version') { // the version command will update the api version header for testing percy.testing.version = body; @@ -262,7 +262,7 @@ export function createPercyServer(percy, port) { })) // returns an array of raw logs from the logger .route('get', '/test/logs', (req, res) => res.json(200, { - logs: Array.from(logger.instance.messages) + logs: logger.instance.query(() => true) })) // serves a very basic html page for testing snapshots .route('get', '/test/snapshot', (req, res) => { diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index a36f0df2d..88fe7d26a 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -217,9 +217,8 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { resources = resources.flat(); // include associated snapshot logs matched by meta information - resources.push(createLogResource(logger.query(log => ( - log.meta.snapshot?.testCase === snapshot.meta.snapshot.testCase && log.meta.snapshot?.name === snapshot.meta.snapshot.name - )))); + resources.push(createLogResource(logger.snapshotLogs(snapshot.meta.snapshot))); + logger.evictSnapshot(snapshot.meta.snapshot); if (process.env.PERCY_GZIP) { for (let index = 0; index < resources.length; index++) { diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index e0a5731fd..a1db6bbe7 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -667,7 +667,7 @@ describe('API Server', () => { beforeEach(async () => { process.env.PERCY_TOKEN = 'TEST_TOKEN'; percy = await Percy.start({ testing: true }); - logger.instance.messages.clear(); + logger.instance.reset(); }); afterEach(() => { diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 7c5e0ac55..b39270569 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1180,7 +1180,7 @@ describe('Percy', () => { percy.log.info('cli_test'); percy.log.info('ci_test', {}, true); const logsObject = { - clilogs: Array.from(logger.instance.messages) + clilogs: logger.instance.query(() => true) }; const content = base64encode(Pako.gzip(JSON.stringify(logsObject))); diff --git a/packages/logger/src/index.js b/packages/logger/src/index.js index 919bf5cf6..d1e729df5 100644 --- a/packages/logger/src/index.js +++ b/packages/logger/src/index.js @@ -11,6 +11,9 @@ Object.defineProperties(logger, { constructor: { get: () => Logger }, instance: { get: () => new Logger() }, query: { value: (...args) => logger.instance.query(...args) }, + snapshotLogs: { value: (...args) => logger.instance.snapshotLogs(...args) }, + evictSnapshot: { value: (...args) => logger.instance.evictSnapshot(...args) }, + reset: { value: (...args) => logger.instance.reset(...args) }, format: { value: (...args) => logger.instance.format(...args) }, loglevel: { value: (...args) => logger.instance.loglevel(...args) }, timeit: { get: () => new TimeIt(logger.instance.group('timer')) }, diff --git a/packages/logger/src/logger.js b/packages/logger/src/logger.js index a1c76fb23..f2009b758 100644 --- a/packages/logger/src/logger.js +++ b/packages/logger/src/logger.js @@ -1,32 +1,66 @@ +import fs from 'fs'; +import { tmpdir } from 'os'; +import { join, dirname } from 'path'; +import { randomBytes } from 'crypto'; import { colors } from './utils.js'; const LINE_PAD_REGEXP = /^(\n*)(.*?)(\n*)$/s; const URL_REGEXP = /https?:\/\/[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:;%_+.~#?&//=[\]]*)/i; const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; -// A PercyLogger instance retains logs in-memory for quick lookups while also writing log -// messages to stdout and stderr depending on the log level and debug string. +const FLUSH_AT_ENTRIES = 500; +const FLUSH_TIMER_MS = 100; +const READ_CHUNK_BYTES = 64 * 1024; + +// Hooks latch + active-instance set kept on the `process` object via Symbol.for +// so that they are shared across module copies (the ESM loader-mock path used +// by tests creates fresh module instances; a module-scoped variable would let +// each one register its own listener and accumulate into MaxListenersWarning). +// Using a Set rather than a single pointer also handles transitional states +// where two PercyLogger instances are alive at once (e.g. test setups that +// don't reset between cases) — both files get cleaned at exit. +const EXIT_HOOKS_INSTALLED = Symbol.for('@percy/logger.exitHooksInstalled'); +const ACTIVE_INSTANCES = Symbol.for('@percy/logger.activeInstances'); + +// A PercyLogger writes logs to stdout/stderr and persists every entry to a +// JSONL file under os.tmpdir()/percy-logs//, keeping resident memory +// bounded across long builds. Falls back to an unbounded in-memory Set if +// disk is unavailable (or if the rollback env var PERCY_LOGS_IN_MEMORY is set). export class PercyLogger { - // default log level level = 'info'; - // namespace regular expressions used to determine which debug logs to write namespaces = { include: [/^.*?$/], exclude: [/^ci$/, /^sdk$/] }; - // in-memory store for logs and meta info - messages = new Set(); - - // track deprecations to limit noisy logging deprecations = new Set(); - // static vars can be overriden for testing + // disk-backed store state + diskMode = 'disk'; + diskPath = null; + diskSize = 0; + writeBuffer = []; + flushTimer = null; + // snapshotLogs cache: Map. Bounded by # of un-evicted snapshot + // keys at any moment; evictSnapshot() drops them. Entries logged AFTER the + // eviction repopulate the cache through the next _refreshCache delta. Pre- + // eviction entries are restored on retry via the pendingFullScan re-scan in + // snapshotLogs (preserves master's `messages` Set retain-everything semantics + // for retried snapshots). + cache = new Map(); + cacheCursor = 0; + pendingFullScan = new Set(); + fallback = null; + // Lazy Map index over fallback Set. Populated on first + // snapshotLogs() call and maintained by _record. Avoids O(N²) scans when + // PERCY_LOGS_IN_MEMORY=1 is the active mode for a long-running build. + fallbackByKey = null; + writeFailureWarned = false; + static stdout = process.stdout; static stderr = process.stderr; - // Handles setting env var values and returns a singleton constructor() { let { instance = this } = this.constructor; @@ -36,18 +70,30 @@ export class PercyLogger { instance.loglevel(process.env.PERCY_LOGLEVEL); } + // If the rollback / test env var is set, flip to memory mode immediately so + // log() never goes through the disk buffer at all. Drain any entries that + // were already queued in disk mode so they aren't stranded after the flip. + // Note: PERCY_LOGS_IN_MEMORY is only consulted here at construction time; + // setting or unsetting it later has no effect because subsequent + // `new Logger()` returns the cached singleton — tests that need to flip + // mode mid-process must `delete logger.constructor.instance` first. + if (process.env.PERCY_LOGS_IN_MEMORY === '1' && + instance.diskMode === 'disk' && !instance.diskPath) { + instance.diskMode = 'memory'; + instance.fallback ??= new Set(); + /* istanbul ignore if: only triggered when env=1 is set after logs have already buffered */ + if (instance.writeBuffer.length) instance._drainBufferToMemory(); + } + this.constructor.instance = instance; return instance; } - // Change log level at any time or return the current log level loglevel(level) { if (level) this.level = level; return this.level; } - // Change namespaces by generating an array of namespace regular expressions from a - // comma separated debug string debug(namespaces) { if (this.namespaces.string === namespaces) return; this.namespaces.string = namespaces; @@ -73,7 +119,6 @@ export class PercyLogger { }); } - // Creates a new log group and returns level specific functions for logging group(name) { return Object.keys(LOG_LEVELS) .reduce((group, level) => Object.assign(group, { @@ -89,61 +134,140 @@ export class PercyLogger { }); } - // Query for a set of logs by filtering the in-memory store + // Returns matching entries. The semantics differ by mode: + // - memory mode: returns the live entry refs from the fallback Set; mutations + // to entry.message (e.g. redactSecrets in percy.js sendBuildLogs) persist + // in the Set. This mirrors master's `messages` contract. + // - disk mode: streams a fresh JSONL pass per call and returns freshly-parsed + // copies. Mutations are local to the caller and never reach disk — + // intentional, since disk-backed redaction would require a rewrite. + // Production consumers (sendBuildLogs) only depend on the array returned by + // redactSecrets, not the mutation side-effect, so both modes are correct. query(filter) { - return Array.from(this.messages).filter(filter); + if (this.diskMode === 'memory') { + return Array.from(this.fallback).filter(filter); + } + + this._flushSync(); + if (this.diskMode === 'memory') { + return Array.from(this.fallback).filter(filter); + } + return this._scanDisk(filter); + } + + // Returns entries tagged with the given snapshot meta. In disk mode, reads + // only the disk delta since the last call to amortize the work in defer + // mode (snapshots accumulate; logs route through the cache lazily). On + // retry — when evictSnapshot was called and snapshotLogs is invoked again + // for the same meta — pre-eviction entries are recovered from a full + // disk scan so the per-snapshot log resource includes both attempts. + // Returns a shallow copy so callers can mutate without corrupting the cache. + snapshotLogs(meta) { + let key = this._snapshotKey({ snapshot: meta }); + if (!key) return []; + + if (this.diskMode === 'memory') { + return [...this._filterFallback(key)]; + } + + this._flushSync(); + /* istanbul ignore if: defensive — _flushSync only flips mode via _fallbackToMemory, which our snapshotLogs tests don't exercise mid-call */ + if (this.diskMode === 'memory') { + return [...this._filterFallback(key)]; + } + this._refreshCache(); + + // Retry path: this key was previously evicted. The incremental cursor has + // already advanced past its prior entries, so a delta-only refresh would + // miss them. Re-scan the entire JSONL once for this key to restore parity + // with master (where `messages = new Set()` retained every entry). + if (this.pendingFullScan.has(key)) { + this.pendingFullScan.delete(key); + let full = this._scanDisk(e => this._snapshotKey(e?.meta) === key); + // If full.length is 0 the key already has no cache entry (evictSnapshot + // deleted it) — leave it absent so cache.get returns undefined below. + if (full.length) this.cache.set(key, full); + } + + let cached = this.cache.get(key); + return cached ? [...cached] : []; + } + + evictSnapshot(meta) { + let key = this._snapshotKey({ snapshot: meta }); + if (!key) return; + this.cache.delete(key); + // Mark for full-disk rescan on the next snapshotLogs(meta) — needed so a + // retry/re-discovery flow recovers the pre-eviction entries (master + // parity). Cleared once consumed to keep the rescan one-shot per evict. + this.pendingFullScan.add(key); + } + + // Resets all logger state. Cleans up the disk file; next log will lazily reinit. + reset() { + // Why: discard buffered entries before _cleanup — between tests, the old + // diskPath may reference a file from a prior mockfs volume that no longer + // exists in the real fs. Letting _flushSync run would trip ENOENT and + // emit the disk-fallback warning into the next test's captured stderr. + this.writeBuffer = []; + /* istanbul ignore if: defensive — the only code path that schedules a + timer also drains via query() before reset() in tests */ + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this._cleanup(); + process[ACTIVE_INSTANCES]?.delete(this); + this.diskPath = null; + this.diskSize = 0; + this.cache.clear(); + this.cacheCursor = 0; + this.pendingFullScan.clear(); + this.fallback = null; + this.fallbackByKey = null; + this.diskMode = 'disk'; + this.writeFailureWarned = false; + this.deprecations = new Set(); } - // Formats messages before they are logged to stdio format(debug, level, message, elapsed) { let color = (n, m) => this.isTTY ? colors[n](m) : m; let begin, end, suffix = ''; let label = 'percy'; if (arguments.length === 1) { - // format(message) [debug, message] = [null, debug]; } else if (arguments.length === 2) { - // format(debug, message) [level, message] = [null, level]; } - // do not format leading or trailing newlines [, begin, message, end] = message.match(LINE_PAD_REGEXP); - // include debug information if (this.level === 'debug') { if (debug) label += `:${debug}`; - // include elapsed time since last log if (elapsed != null) { suffix = ' ' + color('grey', `(${elapsed}ms)`); } } - // add colors label = color('magenta', label); if (level === 'error') { - // red errors message = color('red', message); } else if (level === 'warn') { - // yellow warnings message = color('yellow', message); } else if (level === 'info' || level === 'debug') { - // blue info and debug URLs message = message.replace(URL_REGEXP, color('blue', '$&')); } return `${begin}[${label}] ${message}${suffix}${end}`; } - // True if stdout is a TTY interface get isTTY() { return !!this.constructor.stdout.isTTY; } - // Replaces the current line with a log message progress(debug, message, persist) { if (!this.shouldLog(debug, 'info')) return; let { stdout } = this.constructor; @@ -159,7 +283,6 @@ export class PercyLogger { this._progress = !!message && { message, persist }; } - // Returns true or false if the level and debug group can write messages to stdio shouldLog(debug, level) { return LOG_LEVELS[level] != null && LOG_LEVELS[level] >= LOG_LEVELS[this.level] && @@ -167,7 +290,6 @@ export class PercyLogger { this.namespaces.include.some(ns => ns.test(debug)); } - // Ensures that deprecation messages are not logged more than once deprecated(debug, message, meta) { if (this.deprecations.has(message)) return; this.deprecations.add(message); @@ -175,36 +297,29 @@ export class PercyLogger { this.log(debug, 'warn', `Warning: ${message}`, meta); } - // Generic log method accepts a debug group, log level, log message, and optional meta - // information to store with the message and other info log(debug, level, message, meta = {}) { - // message might be an error-like object let err = typeof message !== 'string' && (level === 'debug' || level === 'error'); err &&= message.message ? Error.prototype.toString.call(message) : message.toString(); - // save log entries let timestamp = Date.now(); message = err ? (message.stack || err) : message.toString(); let entry = { debug, level, message, meta, timestamp, error: !!err }; - this.messages.add(entry); - // maybe write the message to stdio + this._record(entry); + if (this.shouldLog(debug, level)) { - // unless the loglevel is debug, write shorter error messages if (err && this.level !== 'debug') message = err; this.write({ ...entry, message }); this.lastlog = timestamp; } } - // Writes a log entry to stdio based on the loglevel write({ debug, level, message, timestamp, error }) { let elapsed = timestamp - (this.lastlog || timestamp); let msg = this.format(debug, error ? 'error' : level, message, elapsed); let progress = this.isTTY && this._progress; let { stdout, stderr } = this.constructor; - // clear any logged progress if (progress) { stdout.cursorTo(0); stdout.clearLine(0); @@ -214,6 +329,277 @@ export class PercyLogger { if (!this._progress?.persist) delete this._progress; else if (progress) stdout.write(progress.message); } + + // ── internals ─────────────────────────────────────────────────────────────── + + _filterFallback(key) { + if (!this.fallbackByKey) { + // Lazy build: O(N) once, then O(1) per call. Keeps PERCY_LOGS_IN_MEMORY=1 + // mode usable for 10k-snapshot builds where snapshotLogs is called per + // snapshot. + this.fallbackByKey = new Map(); + for (let entry of this.fallback) { + let k = this._snapshotKey(entry?.meta); + if (!k) continue; + let arr = this.fallbackByKey.get(k); + if (!arr) this.fallbackByKey.set(k, arr = []); + arr.push(entry); + } + } + return this.fallbackByKey.get(key) || []; + } + + _record(entry) { + if (this.diskMode === 'memory') { + this.fallback.add(entry); + // Maintain the lazy index incrementally if it exists. If it hasn't been + // built yet, _filterFallback will scan fallback once on next call. + if (this.fallbackByKey) { + let k = this._snapshotKey(entry?.meta); + if (k) { + let arr = this.fallbackByKey.get(k); + if (!arr) this.fallbackByKey.set(k, arr = []); + arr.push(entry); + } + } + return; + } + + let line; + try { + line = JSON.stringify(entry) + '\n'; + } catch { + // Why: circular references in meta would otherwise kill this log call. + // Preserve meta.snapshot so the entry still routes via snapshotLogs. + let safeMeta = { unserializable: true }; + if (entry.meta?.snapshot) safeMeta.snapshot = entry.meta.snapshot; + entry = { ...entry, meta: safeMeta }; + line = JSON.stringify(entry) + '\n'; + } + + let length = Buffer.byteLength(line, 'utf8'); + this.writeBuffer.push({ line, length }); + this._scheduleFlush(); + + if (this.writeBuffer.length >= FLUSH_AT_ENTRIES) this._flushSync(); + } + + _snapshotKey(meta) { + let s = meta?.snapshot; + if (!s || (!s.testCase && !s.name)) return null; + // NUL byte separator — `|` collides on legitimate names like + // ('a|b','c') vs ('a','b|c'); NUL is forbidden in test/snapshot names. + return `${s.testCase || ''}\x00${s.name || ''}`; + } + + _scheduleFlush() { + if (this.flushTimer) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this._flushSync(); + }, FLUSH_TIMER_MS); + this.flushTimer.unref?.(); + } + + _flushSync() { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (!this.writeBuffer.length) return; + + this._ensureDiskInit(); + if (this.diskMode !== 'disk') { + this._drainBufferToMemory(); + return; + } + + let chunk = ''; + let written = 0; + for (let item of this.writeBuffer) { + chunk += item.line; + written += item.length; + } + + try { + fs.appendFileSync(this.diskPath, chunk); + } catch (err) { + this._fallbackToMemory(err); + return; + } + + this.diskSize += written; + this.writeBuffer = []; + } + + _drainBufferToMemory() { + /* istanbul ignore next: defensive — fallback is always set before drain runs */ + if (!this.fallback) this.fallback = new Set(); + for (let { line } of this.writeBuffer) { + /* istanbul ignore next: defensive — entries are JSON.stringify'd by us */ + try { this.fallback.add(JSON.parse(line.replace(/\n$/, ''))); } catch { /* skip */ } + } + this.writeBuffer = []; + } + + _ensureDiskInit() { + if (this.diskPath || this.diskMode !== 'disk') return; + + try { + // Per-pid subdir keeps concurrent percy processes (CI matrix, parallel + // test workers, npx invocations) from clobbering each other's files. + let dir = join(tmpdir(), 'percy-logs', String(process.pid)); + fs.mkdirSync(dir, { recursive: true }); + this.diskPath = join( + dir, + `${Date.now()}-${randomBytes(8).toString('hex')}.jsonl` + ); + fs.writeFileSync(this.diskPath, ''); + this._installExitHooks(); + } catch { + this.diskMode = 'memory'; + /* istanbul ignore next: defensive — fallback may already be set */ + this.fallback ??= new Set(); + this.diskPath = null; + } + } + + // Reads the disk delta into the snapshotLogs cache, grouped by snapshotKey. + // Streams in 64KB chunks so a long defer-mode build draining hundreds of MB + // at end-of-build doesn't allocate a single huge buffer. + _refreshCache() { + if (this.cacheCursor >= this.diskSize) return; + let fd = fs.openSync(this.diskPath, 'r'); + + try { + let buf = Buffer.alloc(READ_CHUNK_BYTES); + let offset = this.cacheCursor; + let partial = ''; + while (offset < this.diskSize) { + let toRead = Math.min(READ_CHUNK_BYTES, this.diskSize - offset); + let n = fs.readSync(fd, buf, 0, toRead, offset); + offset += n; + let lines = (partial + buf.slice(0, n).toString('utf8')).split('\n'); + partial = lines.pop(); + for (let line of lines) { + let entry; + /* istanbul ignore next: defensive — entries are JSON.stringify'd by us */ + try { entry = JSON.parse(line); } catch { continue; } + let key = this._snapshotKey(entry?.meta); + if (!key) continue; + let arr = this.cache.get(key); + if (!arr) this.cache.set(key, arr = []); + arr.push(entry); + } + } + this.cacheCursor = this.diskSize; + } finally { + fs.closeSync(fd); + } + } + + // Streams the JSONL once and returns matching entries. Each call parses + // afresh — no parsed-entry cache, so RSS at upload time stays bounded by + // the size of the filtered result rather than the total log volume. + _scanDisk(filter) { + if (!this.diskPath || this.diskSize === 0) return []; + + let result = []; + let fd = fs.openSync(this.diskPath, 'r'); + + try { + let buf = Buffer.alloc(READ_CHUNK_BYTES); + let offset = 0; + let partial = ''; + while (offset < this.diskSize) { + let toRead = Math.min(READ_CHUNK_BYTES, this.diskSize - offset); + let n = fs.readSync(fd, buf, 0, toRead, offset); + offset += n; + let lines = (partial + buf.slice(0, n).toString('utf8')).split('\n'); + partial = lines.pop(); + for (let line of lines) { + /* istanbul ignore next: defensive — entries are JSON.stringify'd by us */ + try { + let entry = JSON.parse(line); + if (filter(entry)) result.push(entry); + } catch { /* skip */ } + } + } + } finally { + fs.closeSync(fd); + } + return result; + } + + _fallbackToMemory(err) { + /* istanbul ignore else: latch — only fires once per build */ + if (!this.writeFailureWarned) { + this.writeFailureWarned = true; + PercyLogger.stderr.write( + `[percy] logger: disk write failed (${err?.code || err?.message || 'unknown'}), falling back to in-memory\n` + ); + } + + // Read whatever we already wrote to disk into the fallback Set so /logs + // upload still includes everything from before the failure. + let existing = []; + if (this.diskPath && this.diskSize > 0) { + /* istanbul ignore next: defensive — _scanDisk handles its own errors */ + try { existing = this._scanDisk(() => true); } catch { /* tolerate */ } + } + + this.diskMode = 'memory'; + /* istanbul ignore next: defensive — fallback may already be set */ + this.fallback ??= new Set(); + for (let entry of existing) this.fallback.add(entry); + this._drainBufferToMemory(); + + /* istanbul ignore else: latch — diskPath always set on first fallback */ + if (this.diskPath) { + /* istanbul ignore next: defensive — best-effort cleanup */ + try { fs.unlinkSync(this.diskPath); } catch { /* tolerate */ } + this.diskPath = null; + } + this.diskSize = 0; + this.cache.clear(); + this.cacheCursor = 0; + this.pendingFullScan.clear(); + this.fallbackByKey = null; + } + + _installExitHooks() { + let active = process[ACTIVE_INSTANCES] ??= new Set(); + active.add(this); + /* istanbul ignore if: latch — only the first install per process */ + if (process[EXIT_HOOKS_INSTALLED]) return; + process[EXIT_HOOKS_INSTALLED] = true; + let cleanup = () => { + for (let logger of process[ACTIVE_INSTANCES]) logger._cleanup(); + }; + process.once('exit', cleanup); + process.once('beforeExit', cleanup); + // Why: SIGINT/SIGTERM are intentionally not handled. The CLI runtime + // already installs its own signal listeners; adding ours pushes past + // the default 10-listener limit and trips MaxListenersExceededWarning + // in downstream test suites. On Ctrl-C / runner kill our JSONL is left + // in os.tmpdir()/percy-logs// which the OS cleans, and the + // pid-scoped subdir prevents concurrent runs from colliding. + } + + _cleanup() { + /* istanbul ignore next: defensive — flush should not throw */ + try { this._flushSync(); } catch { /* tolerate */ } + if (this.diskPath) { + let dir = dirname(this.diskPath); + /* istanbul ignore next: defensive — best-effort */ + try { fs.unlinkSync(this.diskPath); } catch { /* tolerate */ } + // Best-effort rmdir of the per-pid subdir so long-lived runners don't + // accumulate empty directories. Fails harmlessly if peer instances of + // the same pid still hold files there. + /* istanbul ignore next: defensive — best-effort */ + try { fs.rmdirSync(dir); } catch { /* tolerate */ } + } + } } export default PercyLogger; diff --git a/packages/logger/test/helpers.js b/packages/logger/test/helpers.js index 869665533..cc5aea898 100644 --- a/packages/logger/test/helpers.js +++ b/packages/logger/test/helpers.js @@ -43,6 +43,14 @@ const helpers = { }, async mock(options = {}) { + // Default to memory mode for downstream packages whose test setups don't + // explicitly select a mode. Logger tests that want disk mode set + // PERCY_LOGS_IN_MEMORY explicitly before calling mock(); the value here + // is only applied when the env hasn't already been pinned. + if (!('PERCY_LOGS_IN_MEMORY' in process.env)) { + process.env.PERCY_LOGS_IN_MEMORY = '1'; + } + helpers.reset(); if (options.level) { @@ -78,8 +86,13 @@ const helpers = { }, reset(soft) { - if (soft) logger.loglevel('info'); - else delete logger.constructor.instance; + if (soft) { + logger.loglevel('info'); + } else { + // tear down the prior instance's disk artifacts before swapping it out + try { logger.constructor.instance?.reset(); } catch { /* tolerate */ } + delete logger.constructor.instance; + } helpers.stdout.length = 0; helpers.stderr.length = 0; @@ -92,7 +105,7 @@ const helpers = { }, dump() { - let msgs = Array.from(logger.instance.messages); + let msgs = logger.instance.query(() => true); if (!msgs.length) return; let log = m => process.env.__PERCY_BROWSERIFIED__ ? ( diff --git a/packages/logger/test/logger.test.js b/packages/logger/test/logger.test.js index 92b1a7a8b..e5560352a 100644 --- a/packages/logger/test/logger.test.js +++ b/packages/logger/test/logger.test.js @@ -1,568 +1,1111 @@ +import fs, { existsSync, readFileSync, readdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, sep, dirname } from 'path'; import helpers from './helpers.js'; import { colors } from '@percy/logger/utils'; import logger from '@percy/logger'; -describe('logger', () => { - let log, inst; +// Parameterize the entire suite to run in both store modes — memory mode +// (master parity, fallback Set) and disk mode (production hot path). The +// inner disk-backed-storage describe owns its own env management and runs +// in both wrappers. +['memory', 'disk'].forEach(__mode => { + describe(`logger (${__mode} mode)`, () => { + let log, inst; - beforeEach(async () => { - await helpers.mock({ ansi: true, isTTY: true }); - inst = logger.instance; - log = logger('test'); - }); - - afterEach(() => { - delete process.env.PERCY_LOGLEVEL; - delete process.env.PERCY_DEBUG; - }); + beforeEach(async () => { + if (__mode === 'disk') delete process.env.PERCY_LOGS_IN_MEMORY; + else process.env.PERCY_LOGS_IN_MEMORY = '1'; + await helpers.mock({ ansi: true, isTTY: true }); + inst = logger.instance; + log = logger('test'); + }); - it('creates a namespaced logger', () => { - expect(log).toHaveProperty('info', jasmine.any(Function)); - expect(log).toHaveProperty('warn', jasmine.any(Function)); - expect(log).toHaveProperty('error', jasmine.any(Function)); - expect(log).toHaveProperty('debug', jasmine.any(Function)); - expect(log).toHaveProperty('progress', jasmine.any(Function)); - expect(log).toHaveProperty('deprecated', jasmine.any(Function)); - }); + afterEach(() => { + delete process.env.PERCY_LOGLEVEL; + delete process.env.PERCY_DEBUG; + delete process.env.PERCY_LOGS_IN_MEMORY; + }); - it('has a default log level', () => { - expect(log.loglevel()).toEqual('info'); - expect(logger.loglevel()).toEqual('info'); - }); + it('creates a namespaced logger', () => { + expect(log).toHaveProperty('info', jasmine.any(Function)); + expect(log).toHaveProperty('warn', jasmine.any(Function)); + expect(log).toHaveProperty('error', jasmine.any(Function)); + expect(log).toHaveProperty('debug', jasmine.any(Function)); + expect(log).toHaveProperty('progress', jasmine.any(Function)); + expect(log).toHaveProperty('deprecated', jasmine.any(Function)); + }); - it('saves logs to an in-memory store', () => { - log.info('Info log', { foo: 'bar' }); - log.warn('Warn log', { bar: 'baz' }); - log.error('Error log', { to: 'be' }); - log.debug('Debug log', { not: 'to be' }); - log.deprecated('Deprecation log', { test: 'me' }); - - let entry = (level, message, meta) => ({ - timestamp: jasmine.any(Number), - debug: 'test', - error: false, - level, - message, - meta + it('has a default log level', () => { + expect(log.loglevel()).toEqual('info'); + expect(logger.loglevel()).toEqual('info'); }); - expect(inst.messages).toEqual(new Set([ - entry('info', 'Info log', { foo: 'bar' }), - entry('warn', 'Warn log', { bar: 'baz' }), - entry('error', 'Error log', { to: 'be' }), - entry('debug', 'Debug log', { not: 'to be' }), - entry('warn', 'Warning: Deprecation log', { test: 'me' }) - ])); - }); + it('saves logs to an in-memory store', () => { + log.info('Info log', { foo: 'bar' }); + log.warn('Warn log', { bar: 'baz' }); + log.error('Error log', { to: 'be' }); + log.debug('Debug log', { not: 'to be' }); + log.deprecated('Deprecation log', { test: 'me' }); + + let entry = (level, message, meta) => ({ + timestamp: jasmine.any(Number), + debug: 'test', + error: false, + level, + message, + meta + }); + + expect(inst.query(() => true)).toEqual([ + entry('info', 'Info log', { foo: 'bar' }), + entry('warn', 'Warn log', { bar: 'baz' }), + entry('error', 'Error log', { to: 'be' }), + entry('debug', 'Debug log', { not: 'to be' }), + entry('warn', 'Warning: Deprecation log', { test: 'me' }) + ]); + }); - it('writes info logs to stdout', () => { - log.info('Info log'); + it('writes info logs to stdout', () => { + log.info('Info log'); - expect(helpers.stderr).toEqual([]); - expect(helpers.stdout).toEqual([ + expect(helpers.stderr).toEqual([]); + expect(helpers.stdout).toEqual([ `[${colors.magenta('percy')}] Info log` - ]); - }); + ]); + }); - it('writes warning and error logs to stderr', () => { - log.warn('Warn log'); - log.error('Error log'); + it('writes warning and error logs to stderr', () => { + log.warn('Warn log'); + log.error('Error log'); - expect(helpers.stdout).toEqual([]); - expect(helpers.stderr).toEqual([ + expect(helpers.stdout).toEqual([]); + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy')}] ${colors.yellow('Warn log')}`, `[${colors.magenta('percy')}] ${colors.red('Error log')}` - ]); - }); + ]); + }); - it('highlights info URLs blue', () => { - let url = 'https://percy.io/?foo[bar]=baz&qux=quux:xyzzy;'; - log.info(`URL: ${url}`); + it('highlights info URLs blue', () => { + let url = 'https://percy.io/?foo[bar]=baz&qux=quux:xyzzy;'; + log.info(`URL: ${url}`); - expect(helpers.stdout).toEqual([ + expect(helpers.stdout).toEqual([ `[${colors.magenta('percy')}] URL: ${colors.blue(url)}` - ]); - }); - - it('captures error stack traces without writing them', () => { - let error = new Error('test'); - log.error(error); - - expect(inst.messages).toContain({ - debug: 'test', - level: 'error', - message: error.stack, - timestamp: jasmine.any(Number), - error: true, - meta: {} + ]); }); - expect(helpers.stderr).toEqual([ + it('captures error stack traces without writing them', () => { + let error = new Error('test'); + log.error(error); + + expect(inst.query(() => true)).toContain({ + debug: 'test', + level: 'error', + message: error.stack, + timestamp: jasmine.any(Number), + error: true, + meta: {} + }); + + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy')}] ${colors.red('Error: test')}` - ]); - }); + ]); + }); - it('does not write debug logs by default', () => { - log.debug('Debug log'); - expect(helpers.stdout).toEqual([]); - expect(helpers.stderr).toEqual([]); - }); + it('does not write debug logs by default', () => { + log.debug('Debug log'); + expect(helpers.stdout).toEqual([]); + expect(helpers.stderr).toEqual([]); + }); - it('prevents duplicate deprecation logs', () => { - log.deprecated('Update me'); - log.deprecated('Update me'); - log.deprecated('Update me'); - log.deprecated('Update me too'); + it('prevents duplicate deprecation logs', () => { + log.deprecated('Update me'); + log.deprecated('Update me'); + log.deprecated('Update me'); + log.deprecated('Update me too'); - expect(helpers.stderr).toEqual([ + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy')}] ${colors.yellow('Warning: Update me')}`, `[${colors.magenta('percy')}] ${colors.yellow('Warning: Update me too')}` - ]); - }); + ]); + }); - it('can query for logs from the in-memory store', () => { - log.info('Not me', { match: false }); - log.info('Not me', { match: false }); - log.info('Yes me', { match: true }); - log.info('Not me', { match: false }); - log.info('Not me', { match: false }); - - expect(logger.query(m => m.meta.match)).toEqual([{ - debug: 'test', - level: 'info', - message: 'Yes me', - timestamp: jasmine.any(Number), - meta: { match: true }, - error: false - }]); - }); + it('can query for logs from the in-memory store', () => { + log.info('Not me', { match: false }); + log.info('Not me', { match: false }); + log.info('Yes me', { match: true }); + log.info('Not me', { match: false }); + log.info('Not me', { match: false }); + + expect(logger.query(m => m.meta.match)).toEqual([{ + debug: 'test', + level: 'info', + message: 'Yes me', + timestamp: jasmine.any(Number), + meta: { match: true }, + error: false + }]); + }); - it('does not write to stdout if CI log', () => { - log = logger('ci'); - log.info('Dont print me'); + it('does not write to stdout if CI log', () => { + log = logger('ci'); + log.info('Dont print me'); - expect(helpers.stdout).toEqual([]); - }); + expect(helpers.stdout).toEqual([]); + }); - it('does not write to stdout if SDK log', () => { - log = logger('sdk'); - log.info('Dont print me'); + it('does not write to stdout if SDK log', () => { + log = logger('sdk'); + log.info('Dont print me'); - expect(helpers.stdout).toEqual([]); - }); + expect(helpers.stdout).toEqual([]); + }); - it('exposes a message formatting method', () => { - expect(log.format('grouped')).toEqual( + it('exposes a message formatting method', () => { + expect(log.format('grouped')).toEqual( `[${colors.magenta('percy')}] grouped`); - expect(log.format('warn', 'level')).toEqual( + expect(log.format('warn', 'level')).toEqual( `[${colors.magenta('percy')}] ${colors.yellow('level')}`); - expect(log.format('error', 'level')).toEqual( + expect(log.format('error', 'level')).toEqual( `[${colors.magenta('percy')}] ${colors.red('level')}`); - expect(logger.format('ungrouped')).toEqual( + expect(logger.format('ungrouped')).toEqual( `[${colors.magenta('percy')}] ungrouped`); - expect(logger.format('other', 'long')).toEqual( + expect(logger.format('other', 'long')).toEqual( `[${colors.magenta('percy')}] long`); - expect(logger.format('other', 'warn', 'long level')).toEqual( + expect(logger.format('other', 'warn', 'long level')).toEqual( `[${colors.magenta('percy')}] ${colors.yellow('long level')}`); - expect(logger.format('other', 'error', 'elapsed', 100)).toEqual( + expect(logger.format('other', 'error', 'elapsed', 100)).toEqual( `[${colors.magenta('percy')}] ${colors.red('elapsed')}`); - log.loglevel('debug'); + log.loglevel('debug'); - expect(log.format('grouped')).toEqual( + expect(log.format('grouped')).toEqual( `[${colors.magenta('percy:test')}] grouped`); - expect(log.format('error', 'level')).toEqual( + expect(log.format('error', 'level')).toEqual( `[${colors.magenta('percy:test')}] ${colors.red('level')}`); - expect(logger.format('ungrouped')).toEqual( + expect(logger.format('ungrouped')).toEqual( `[${colors.magenta('percy')}] ungrouped`); - expect(logger.format('other', 'long')).toEqual( + expect(logger.format('other', 'long')).toEqual( `[${colors.magenta('percy:other')}] long`); - expect(logger.format('other', 'warn', 'elapsed', 100)).toEqual( + expect(logger.format('other', 'warn', 'elapsed', 100)).toEqual( `[${colors.magenta('percy:other')}] ` + `${colors.yellow('elapsed')} ${colors.grey('(100ms)')}`); - // does not format leading or trailing newlines - expect(logger.format('padded', 'debug', '\n\nnewlines\n\n', 25)).toEqual( + // does not format leading or trailing newlines + expect(logger.format('padded', 'debug', '\n\nnewlines\n\n', 25)).toEqual( `\n\n[${colors.magenta('percy:padded')}] ` + `newlines ${colors.grey('(25ms)')}\n\n`); - }); - - it('exposes own stdout and stderr streams', () => { - expect(logger.stdout).toBe(logger.constructor.stdout); - expect(logger.stderr).toBe(logger.constructor.stderr); - }); + }); - it('can define a custom instance write method', () => { - let write = logger.instance.write = jasmine.createSpy('write'); + it('exposes own stdout and stderr streams', () => { + expect(logger.stdout).toBe(logger.constructor.stdout); + expect(logger.stderr).toBe(logger.constructor.stderr); + }); - log.info('Info log'); - log.warn('Warn log'); - log.error('Error log'); - log.debug('Debug log'); + it('can define a custom instance write method', () => { + let write = logger.instance.write = jasmine.createSpy('write'); - expect(write).toHaveBeenCalledWith(jasmine.objectContaining( - { debug: 'test', level: 'info', message: 'Info log' })); - expect(write).toHaveBeenCalledWith(jasmine.objectContaining( - { debug: 'test', level: 'warn', message: 'Warn log' })); - expect(write).toHaveBeenCalledWith(jasmine.objectContaining( - { debug: 'test', level: 'error', message: 'Error log' })); + log.info('Info log'); + log.warn('Warn log'); + log.error('Error log'); + log.debug('Debug log'); - // write is not called when a log should not be written - expect(write).not.toHaveBeenCalledWith(jasmine.objectContaining( - { debug: 'test', level: 'debug', message: 'Debug log' })); + expect(write).toHaveBeenCalledWith(jasmine.objectContaining( + { debug: 'test', level: 'info', message: 'Info log' })); + expect(write).toHaveBeenCalledWith(jasmine.objectContaining( + { debug: 'test', level: 'warn', message: 'Warn log' })); + expect(write).toHaveBeenCalledWith(jasmine.objectContaining( + { debug: 'test', level: 'error', message: 'Error log' })); - log.loglevel('debug'); - log.debug('Debug log'); + // write is not called when a log should not be written + expect(write).not.toHaveBeenCalledWith(jasmine.objectContaining( + { debug: 'test', level: 'debug', message: 'Debug log' })); - expect(write).toHaveBeenCalledWith(jasmine.objectContaining( - { debug: 'test', level: 'debug', message: 'Debug log' })); - }); + log.loglevel('debug'); + log.debug('Debug log'); - describe('levels', () => { - it('can be initially set by defining PERCY_LOGLEVEL', () => { - helpers.reset(); - process.env.PERCY_LOGLEVEL = 'error'; - expect(logger.loglevel()).toEqual('error'); + expect(write).toHaveBeenCalledWith(jasmine.objectContaining( + { debug: 'test', level: 'debug', message: 'Debug log' })); }); - it('logs only warnings and errors when loglevel is "warn"', () => { - log.loglevel('warn'); + describe('levels', () => { + it('can be initially set by defining PERCY_LOGLEVEL', () => { + helpers.reset(); + process.env.PERCY_LOGLEVEL = 'error'; + expect(logger.loglevel()).toEqual('error'); + }); - log.info('Info log'); - log.warn('Warn log'); - log.error('Error log'); - log.debug('Debug log'); + it('logs only warnings and errors when loglevel is "warn"', () => { + log.loglevel('warn'); - expect(helpers.stdout).toEqual([]); - expect(helpers.stderr).toEqual([ + log.info('Info log'); + log.warn('Warn log'); + log.error('Error log'); + log.debug('Debug log'); + + expect(helpers.stdout).toEqual([]); + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy')}] ${colors.yellow('Warn log')}`, `[${colors.magenta('percy')}] ${colors.red('Error log')}` - ]); - }); + ]); + }); - it('logs only errors when loglevel is "error"', () => { - log.loglevel('error'); + it('logs only errors when loglevel is "error"', () => { + log.loglevel('error'); - log.info('Info log'); - log.warn('Warn log'); - log.error('Error log'); - log.debug('Debug log'); + log.info('Info log'); + log.warn('Warn log'); + log.error('Error log'); + log.debug('Debug log'); - expect(helpers.stdout).toEqual([]); - expect(helpers.stderr).toEqual([ + expect(helpers.stdout).toEqual([]); + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy')}] ${colors.red('Error log')}` - ]); - }); + ]); + }); - it('logs everything when loglevel is "debug"', () => { - log.loglevel('debug'); + it('logs everything when loglevel is "debug"', () => { + log.loglevel('debug'); - log.info('Info log'); - log.warn('Warn log'); - log.error('Error log'); - log.debug('Debug log'); + log.info('Info log'); + log.warn('Warn log'); + log.error('Error log'); + log.debug('Debug log'); - expect(helpers.stdout).toEqual([ + expect(helpers.stdout).toEqual([ `[${colors.magenta('percy:test')}] Info log` - ]); - expect(helpers.stderr).toEqual([ + ]); + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy:test')}] ${colors.yellow('Warn log')}`, `[${colors.magenta('percy:test')}] ${colors.red('Error log')}`, `[${colors.magenta('percy:test')}] Debug log` - ]); - }); + ]); + }); - it('logs error stack traces when loglevel is "debug"', () => { - let error = new Error('test'); - log.loglevel('debug'); - log.error(error); + it('logs error stack traces when loglevel is "debug"', () => { + let error = new Error('test'); + log.loglevel('debug'); + log.error(error); - expect(helpers.stderr).toEqual([ + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy:test')}] ${colors.red(error.stack)}` - ]); - }); + ]); + }); - it('stringifies error-like objects when loglevel is "debug"', () => { - let errorlike = { name: 'Foo', message: 'bar' }; - let errorstr = { toString: () => 'ERROR' }; - log.loglevel('debug'); - log.debug(errorlike); - log.debug(errorstr); + it('stringifies error-like objects when loglevel is "debug"', () => { + let errorlike = { name: 'Foo', message: 'bar' }; + let errorstr = { toString: () => 'ERROR' }; + log.loglevel('debug'); + log.debug(errorlike); + log.debug(errorstr); - expect(helpers.stderr).toEqual([ + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy:test')}] ${colors.red('Foo: bar')}`, `[${colors.magenta('percy:test')}] ${colors.red('ERROR')}` - ]); - }); + ]); + }); - it('logs elapsed time when loglevel is "debug"', async () => { - await helpers.mock({ elapsed: true }); - logger.loglevel('debug'); - log = logger('test'); + it('logs elapsed time when loglevel is "debug"', async () => { + await helpers.mock({ elapsed: true }); + logger.loglevel('debug'); + log = logger('test'); - log.info('Info log'); - log.warn('Warn log'); - log.error('Error log'); - await new Promise(r => setTimeout(r, 100)); - log.debug('Debug log'); - log.error('Final log'); + log.info('Info log'); + log.warn('Warn log'); + log.error('Error log'); + await new Promise(r => setTimeout(r, 100)); + log.debug('Debug log'); + log.error('Final log'); - expect(helpers.stdout).toEqual([ - jasmine.stringMatching('Info log \\(\\dms\\)') - ]); + expect(helpers.stdout).toEqual([ + jasmine.stringMatching('Info log \\(\\dms\\)') + ]); - expect(helpers.stderr).toEqual([ - jasmine.stringMatching('Warn log \\(\\dms\\)'), - jasmine.stringMatching('Error log \\(\\dms\\)'), - jasmine.stringMatching('Debug log \\(\\d{2,3}ms\\)'), - jasmine.stringMatching('Final log \\(\\dms\\)') - ]); + expect(helpers.stderr).toEqual([ + jasmine.stringMatching('Warn log \\(\\dms\\)'), + jasmine.stringMatching('Error log \\(\\dms\\)'), + jasmine.stringMatching('Debug log \\(\\d{2,3}ms\\)'), + jasmine.stringMatching('Final log \\(\\dms\\)') + ]); + }); }); - }); - describe('debugging', () => { - beforeEach(() => { - helpers.reset(); - }); + describe('debugging', () => { + beforeEach(() => { + helpers.reset(); + }); - it('enables debug logging when PERCY_DEBUG is defined', async () => { - process.env.PERCY_DEBUG = '*'; - await helpers.mock({ ansi: true, isTTY: true }); + it('enables debug logging when PERCY_DEBUG is defined', async () => { + process.env.PERCY_DEBUG = '*'; + await helpers.mock({ ansi: true, isTTY: true }); - logger('test').debug('Debug log'); + logger('test').debug('Debug log'); - expect(logger.loglevel()).toEqual('debug'); - expect(helpers.stderr).toEqual([ + expect(logger.loglevel()).toEqual('debug'); + expect(helpers.stderr).toEqual([ `[${colors.magenta('percy:test')}] Debug log` - ]); - }); + ]); + }); - it('filters specific logs for debugging', async () => { - process.env.PERCY_DEBUG = 'test:*,-test:2,'; - await helpers.mock({ ansi: true }); + it('filters specific logs for debugging', async () => { + process.env.PERCY_DEBUG = 'test:*,-test:2,'; + await helpers.mock({ ansi: true }); - logger('test').debug('Debug test'); - logger('test:1').debug('Debug test 1'); - logger('test:2').debug('Debug test 2'); - logger('test:3').debug('Debug test 3'); + logger('test').debug('Debug test'); + logger('test:1').debug('Debug test 1'); + logger('test:2').debug('Debug test 2'); + logger('test:3').debug('Debug test 3'); - expect(helpers.stderr).toEqual([ - '[percy:test] Debug test', - '[percy:test:1] Debug test 1', - '[percy:test:3] Debug test 3' - ]); - }); + expect(helpers.stderr).toEqual([ + '[percy:test] Debug test', + '[percy:test:1] Debug test 1', + '[percy:test:3] Debug test 3' + ]); + }); - it('does not do anything when PERCY_DEBUG is blank', async () => { - process.env.PERCY_DEBUG = ' '; - await helpers.mock({ ansi: true }); + it('does not do anything when PERCY_DEBUG is blank', async () => { + process.env.PERCY_DEBUG = ' '; + await helpers.mock({ ansi: true }); - logger('test').debug('Debug log'); + logger('test').debug('Debug log'); - expect(logger.loglevel()).toEqual('info'); - expect(helpers.stderr).toEqual([]); + expect(logger.loglevel()).toEqual('info'); + expect(helpers.stderr).toEqual([]); + }); }); - }); - describe('progress', () => { - let stdout; + describe('progress', () => { + let stdout; - let resetSpies = () => { - stdout.cursorTo.calls.reset(); - stdout.clearLine.calls.reset(); - stdout.write.calls.reset(); - }; + let resetSpies = () => { + stdout.cursorTo.calls.reset(); + stdout.clearLine.calls.reset(); + stdout.write.calls.reset(); + }; - beforeEach(async () => { - spyOn(logger.stdout, 'cursorTo').and.callThrough(); - spyOn(logger.stdout, 'clearLine').and.callThrough(); - spyOn(logger.stdout, 'write').and.callThrough(); - ({ stdout } = logger); - }); + beforeEach(async () => { + spyOn(logger.stdout, 'cursorTo').and.callThrough(); + spyOn(logger.stdout, 'clearLine').and.callThrough(); + spyOn(logger.stdout, 'write').and.callThrough(); + ({ stdout } = logger); + }); - it('does not log when loglevel prevents "info" logs', () => { - logger.loglevel('error'); - log.progress('foo'); + it('does not log when loglevel prevents "info" logs', () => { + logger.loglevel('error'); + log.progress('foo'); - expect(stdout.cursorTo).not.toHaveBeenCalled(); - expect(stdout.write).not.toHaveBeenCalled(); - expect(stdout.clearLine).not.toHaveBeenCalled(); - }); + expect(stdout.cursorTo).not.toHaveBeenCalled(); + expect(stdout.write).not.toHaveBeenCalled(); + expect(stdout.clearLine).not.toHaveBeenCalled(); + }); - it('replaces the current log line', () => { - log.progress('foo'); + it('replaces the current log line', () => { + log.progress('foo'); - expect(stdout.cursorTo).toHaveBeenCalledWith(0); - expect(stdout.cursorTo).toHaveBeenCalledBefore(stdout.write); - expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] foo`); - expect(stdout.write).toHaveBeenCalledBefore(stdout.clearLine); - expect(stdout.clearLine).toHaveBeenCalledWith(1); - }); + expect(stdout.cursorTo).toHaveBeenCalledWith(0); + expect(stdout.cursorTo).toHaveBeenCalledBefore(stdout.write); + expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] foo`); + expect(stdout.write).toHaveBeenCalledBefore(stdout.clearLine); + expect(stdout.clearLine).toHaveBeenCalledWith(1); + }); - it('replaces progress with the next log', () => { - log.progress('foo'); - resetSpies(); + it('replaces progress with the next log', () => { + log.progress('foo'); + resetSpies(); - log.info('bar'); + log.info('bar'); - expect(stdout.cursorTo).toHaveBeenCalledWith(0); - expect(stdout.cursorTo).toHaveBeenCalledBefore(stdout.clearLine); - expect(stdout.clearLine).toHaveBeenCalledWith(0); - expect(stdout.clearLine).toHaveBeenCalledBefore(stdout.write); - expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] bar\n`); - }); + expect(stdout.cursorTo).toHaveBeenCalledWith(0); + expect(stdout.cursorTo).toHaveBeenCalledBefore(stdout.clearLine); + expect(stdout.clearLine).toHaveBeenCalledWith(0); + expect(stdout.clearLine).toHaveBeenCalledBefore(stdout.write); + expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] bar\n`); + }); - it('clears last progress when empty', () => { - log.progress('foo'); - resetSpies(); + it('clears last progress when empty', () => { + log.progress('foo'); + resetSpies(); - log.progress(); + log.progress(); - expect(stdout.cursorTo).toHaveBeenCalledWith(0); - expect(stdout.cursorTo).toHaveBeenCalledBefore(stdout.clearLine); - expect(stdout.clearLine).toHaveBeenCalledWith(1); - expect(stdout.write).not.toHaveBeenCalled(); - }); + expect(stdout.cursorTo).toHaveBeenCalledWith(0); + expect(stdout.cursorTo).toHaveBeenCalledBefore(stdout.clearLine); + expect(stdout.clearLine).toHaveBeenCalledWith(1); + expect(stdout.write).not.toHaveBeenCalled(); + }); - it('can persist progress after the next log', () => { - log.progress('foo', true); - resetSpies(); + it('can persist progress after the next log', () => { + log.progress('foo', true); + resetSpies(); - log.info('bar'); + log.info('bar'); + + expect(stdout.cursorTo).toHaveBeenCalledWith(0); + expect(stdout.clearLine).toHaveBeenCalledWith(0); + expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] bar\n`); + expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] foo`); + }); - expect(stdout.cursorTo).toHaveBeenCalledWith(0); - expect(stdout.clearLine).toHaveBeenCalledWith(0); - expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] bar\n`); - expect(stdout.write).toHaveBeenCalledWith(`[${colors.magenta('percy')}] foo`); + describe('without a TTY', () => { + beforeEach(() => { + stdout.isTTY = false; + }); + + it('logs only the first consecutive progress call', () => { + log.progress('foo'); + log.progress('bar'); + log.progress('baz'); + + expect(stdout.cursorTo).not.toHaveBeenCalled(); + expect(stdout.write).toHaveBeenCalledWith('[percy] foo\n'); + expect(stdout.clearLine).not.toHaveBeenCalled(); + }); + + it('does not replace progress with the next log', () => { + log.progress('foo'); + resetSpies(); + + log.info('bar'); + + expect(stdout.cursorTo).not.toHaveBeenCalled(); + expect(stdout.clearLine).not.toHaveBeenCalled(); + expect(stdout.write).toHaveBeenCalledWith('[percy] bar\n'); + }); + + it('ignores consecutive persistant logs after the first', () => { + log.progress('foo', true); + log.info('bar'); + log.progress('baz', true); + log.info('qux'); + + expect(stdout.cursorTo).not.toHaveBeenCalled(); + expect(stdout.write).toHaveBeenCalledTimes(3); + expect(stdout.write).toHaveBeenCalledWith('[percy] foo\n'); + expect(stdout.write).toHaveBeenCalledWith('[percy] bar\n'); + expect(stdout.write).not.toHaveBeenCalledWith('[percy] baz\n'); + expect(stdout.write).toHaveBeenCalledWith('[percy] qux\n'); + expect(stdout.clearLine).not.toHaveBeenCalled(); + }); + }); }); - describe('without a TTY', () => { - beforeEach(() => { - stdout.isTTY = false; + describe('timeit', () => { + describe('measure', () => { + it('should execute async callback and log duration', async () => { + const date1 = new Date(2024, 4, 11, 13, 30, 0); + const date2 = new Date(2024, 4, 11, 13, 31, 0); + const meta = { abc: '123' }; + // Logger internally calls Date.now, so need to mock + // response for it as well. + spyOn(Date, 'now').and.returnValues(date1, date1, date2, date1); + const callback = async () => { + await new Promise((res, _) => setTimeout(res, 20)); + log.info('abcd'); + return 10; + }; + + logger.loglevel('debug'); + const ret = await logger.measure('step', 'test', meta, callback); + expect(ret).toEqual(10); + expect(helpers.stdout).toEqual([ + jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) + ]); + expect(helpers.stderr).toEqual([ + `[${colors.magenta('percy:timer')}] step - test - 60s` + ]); + }); + + it('should execute sync callback and log duration', () => { + const date1 = new Date(2024, 4, 11, 13, 30, 0); + const date2 = new Date(2024, 4, 11, 13, 31, 0); + const meta = { abc: '123' }; + // Logger internally calls Date.now, so need to mock + // response for it as well. + spyOn(Date, 'now').and.returnValues(date1, date1, date2, date1); + const callback = () => { log.info('abcd'); return 10; }; + + logger.loglevel('debug'); + const ret = logger.measure('step', 'test', meta, callback); + expect(ret).toEqual(10); + expect(helpers.stdout).toEqual([ + jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) + ]); + expect(helpers.stderr).toEqual([ + `[${colors.magenta('percy:timer')}] step - test - 60s` + ]); + }); + + it('should capture error info in async', async () => { + const meta = { abc: '123' }; + const error = new Error('Error'); + const callback = async () => { log.info('abcd'); throw error; }; + + logger.loglevel('debug'); + try { + await logger.measure('step', 'test1', meta, callback); + } catch (e) { + expect(e).toEqual(error); + } + expect(helpers.stdout).toEqual([ + jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) + ]); + const mlog = logger.instance.query((msg => msg.debug === 'timer'))[0]; + expect(mlog.meta.errorMsg).toEqual('Error'); + expect(mlog.meta.errorStack).toEqual(jasmine.stringContaining('Error: Error')); + }); + + it('should capture error info in sync', () => { + const meta = { abc: '123' }; + const error = new Error('Error'); + const callback = () => { log.info('abcd'); throw error; }; + + logger.loglevel('debug'); + try { + logger.measure('step', 'test1', meta, callback); + } catch (e) { + expect(e).toEqual(error); + } + expect(helpers.stdout).toEqual([ + jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) + ]); + const mlog = logger.instance.query((msg => msg.debug === 'timer'))[0]; + expect(mlog.meta.errorMsg).toEqual('Error'); + expect(mlog.meta.errorStack).toEqual(jasmine.stringContaining('Error: Error')); + }); }); + }); - it('logs only the first consecutive progress call', () => { - log.progress('foo'); - log.progress('bar'); - log.progress('baz'); + describe('disk-backed storage', () => { + let percyLogsDir = join(tmpdir(), 'percy-logs', String(process.pid)); - expect(stdout.cursorTo).not.toHaveBeenCalled(); - expect(stdout.write).toHaveBeenCalledWith('[percy] foo\n'); - expect(stdout.clearLine).not.toHaveBeenCalled(); + beforeEach(async () => { + delete process.env.PERCY_LOGS_IN_MEMORY; + try { rmSync(percyLogsDir, { recursive: true, force: true }); } catch { /* tolerate */ } + await helpers.mock(); + delete process.env.PERCY_LOGS_IN_MEMORY; + logger.instance.reset(); }); - it('does not replace progress with the next log', () => { - log.progress('foo'); - resetSpies(); + afterEach(() => { + logger.instance.reset(); + try { rmSync(percyLogsDir, { recursive: true, force: true }); } catch { /* tolerate */ } + }); - log.info('bar'); + it('round-trips entries through disk', () => { + let group = logger('disk'); + for (let i = 0; i < 50; i++) group.info(`entry ${i}`, { i }); - expect(stdout.cursorTo).not.toHaveBeenCalled(); - expect(stdout.clearLine).not.toHaveBeenCalled(); - expect(stdout.write).toHaveBeenCalledWith('[percy] bar\n'); + let entries = logger.query(() => true); + expect(entries.length).toEqual(50); + expect(entries[0]).toEqual(jasmine.objectContaining({ message: 'entry 0', meta: { i: 0 } })); + expect(entries[49]).toEqual(jasmine.objectContaining({ message: 'entry 49', meta: { i: 49 } })); }); - it('ignores consecutive persistant logs after the first', () => { - log.progress('foo', true); - log.info('bar'); - log.progress('baz', true); - log.info('qux'); + it('writes a JSONL file under percy-logs', () => { + logger('disk').info('hello'); + logger.query(() => true); // forces flush + + let files = readdirSync(percyLogsDir).filter(f => f.endsWith('.jsonl')); + expect(files.length).toEqual(1); + let content = readFileSync(join(percyLogsDir, files[0]), 'utf8'); + let line = content.trim().split('\n')[0]; + expect(JSON.parse(line)).toEqual(jasmine.objectContaining({ + debug: 'disk', level: 'info', message: 'hello', error: false + })); + }); - expect(stdout.cursorTo).not.toHaveBeenCalled(); - expect(stdout.write).toHaveBeenCalledTimes(3); - expect(stdout.write).toHaveBeenCalledWith('[percy] foo\n'); - expect(stdout.write).toHaveBeenCalledWith('[percy] bar\n'); - expect(stdout.write).not.toHaveBeenCalledWith('[percy] baz\n'); - expect(stdout.write).toHaveBeenCalledWith('[percy] qux\n'); - expect(stdout.clearLine).not.toHaveBeenCalled(); + it('serves snapshotLogs from in-memory cache, not disk', () => { + let group = logger('core:snapshot'); + group.info('A1', { snapshot: { testCase: 'tc', name: 'A' } }); + group.info('B1', { snapshot: { testCase: 'tc', name: 'B' } }); + group.info('A2', { snapshot: { testCase: 'tc', name: 'A' } }); + + let logsA = logger.snapshotLogs({ testCase: 'tc', name: 'A' }); + expect(logsA.length).toEqual(2); + expect(logsA.map(l => l.message)).toEqual(['A1', 'A2']); + + let logsB = logger.snapshotLogs({ testCase: 'tc', name: 'B' }); + expect(logsB.length).toEqual(1); + expect(logsB[0].message).toEqual('B1'); }); - }); - }); - describe('timeit', () => { - describe('measure', () => { - it('should execute async callback and log duration', async () => { - const date1 = new Date(2024, 4, 11, 13, 30, 0); - const date2 = new Date(2024, 4, 11, 13, 31, 0); - const meta = { abc: '123' }; - // Logger internally calls Date.now, so need to mock - // response for it as well. - spyOn(Date, 'now').and.returnValues(date1, date1, date2, date1); - const callback = async () => { - await new Promise((res, _) => setTimeout(res, 20)); - log.info('abcd'); - return 10; - }; + it('on retry after evictSnapshot, snapshotLogs returns BOTH attempts (master parity)', () => { + let group = logger('core:snapshot'); + group.info('A1', { snapshot: { testCase: 'tc', name: 'A' } }); + + // first attempt: upload happens and the snapshot is evicted + expect(logger.snapshotLogs({ testCase: 'tc', name: 'A' }).map(l => l.message)) + .toEqual(['A1']); + logger.evictSnapshot({ testCase: 'tc', name: 'A' }); + + // retry: discovery re-snapshots the same meta and logs again + group.info('A2 (retry)', { snapshot: { testCase: 'tc', name: 'A' } }); + + // snapshotLogs must surface BOTH attempts so the per-snapshot log + // resource is complete — master's `messages = new Set()` retained + // every entry; the disk path mirrors that via a one-shot full-disk + // rescan triggered by the pendingFullScan mark. + let logsA = logger.snapshotLogs({ testCase: 'tc', name: 'A' }); + expect(logsA.map(l => l.message)).toEqual(['A1', 'A2 (retry)']); + expect(logger.query(() => true).find(e => e.message === 'A2 (retry)')).toBeDefined(); + }); - logger.loglevel('debug'); - const ret = await logger.measure('step', 'test', meta, callback); - expect(ret).toEqual(10); - expect(helpers.stdout).toEqual([ - jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) - ]); - expect(helpers.stderr).toEqual([ - `[${colors.magenta('percy:timer')}] step - test - 60s` - ]); + it('snapshotLogs returns a fresh array — caller push/splice does not corrupt cache', () => { + let group = logger('core:snapshot'); + group.info('m1', { snapshot: { testCase: 'tc', name: 'M' } }); + group.info('m2', { snapshot: { testCase: 'tc', name: 'M' } }); + + let first = logger.snapshotLogs({ testCase: 'tc', name: 'M' }); + first.push({ message: 'INJECTED' }); + first.splice(0, 1); + + let second = logger.snapshotLogs({ testCase: 'tc', name: 'M' }); + expect(second.length).toEqual(2); + expect(second.map(l => l.message)).toEqual(['m1', 'm2']); }); - it('should execute sync callback and log duration', () => { - const date1 = new Date(2024, 4, 11, 13, 30, 0); - const date2 = new Date(2024, 4, 11, 13, 31, 0); - const meta = { abc: '123' }; - // Logger internally calls Date.now, so need to mock - // response for it as well. - spyOn(Date, 'now').and.returnValues(date1, date1, date2, date1); - const callback = () => { log.info('abcd'); return 10; }; + it('snapshotKey separator does not collide on names containing "|"', () => { + let group = logger('core:snapshot'); + group.info('first', { snapshot: { testCase: 'a|b', name: 'c' } }); + group.info('second', { snapshot: { testCase: 'a', name: 'b|c' } }); - logger.loglevel('debug'); - const ret = logger.measure('step', 'test', meta, callback); - expect(ret).toEqual(10); - expect(helpers.stdout).toEqual([ - jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) - ]); - expect(helpers.stderr).toEqual([ - `[${colors.magenta('percy:timer')}] step - test - 60s` - ]); + expect(logger.snapshotLogs({ testCase: 'a|b', name: 'c' }).map(l => l.message)) + .toEqual(['first']); + expect(logger.snapshotLogs({ testCase: 'a', name: 'b|c' }).map(l => l.message)) + .toEqual(['second']); }); - it('should capture error info in async', async () => { - const meta = { abc: '123' }; - const error = new Error('Error'); - const callback = async () => { log.info('abcd'); throw error; }; + it('snapshotLogs returns [] for an unknown key', () => { + logger('core:snapshot').info('m1', { snapshot: { name: 'A' } }); + expect(logger.snapshotLogs({ name: 'unknown' })).toEqual([]); + }); - logger.loglevel('debug'); - try { - await logger.measure('step', 'test1', meta, callback); - } catch (e) { - expect(e).toEqual(error); - } - expect(helpers.stdout).toEqual([ - jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) - ]); - const mlog = logger.instance.query((msg => msg.debug === 'timer'))[0]; - expect(mlog.meta.errorMsg).toEqual('Error'); - expect(mlog.meta.errorStack).toEqual(jasmine.stringContaining('Error: Error')); + it('evictSnapshot then snapshotLogs for a key with no disk entries returns []', () => { + // evict a meta we never logged for — pendingFullScan triggers, _scanDisk + // returns nothing, snapshotLogs returns [] (no spurious cache entry). + logger.evictSnapshot({ name: 'never-logged' }); + expect(logger.snapshotLogs({ name: 'never-logged' })).toEqual([]); }); - it('should capture error info in sync', () => { - const meta = { abc: '123' }; - const error = new Error('Error'); - const callback = () => { log.info('abcd'); throw error; }; + it('memory-mode fallbackByKey lazy build groups multiple entries per key', () => { + process.env.PERCY_LOGS_IN_MEMORY = '1'; + delete logger.constructor.instance; + + let group = logger('mem:lazy'); + // Pre-populate fallback with multiple entries for the SAME key plus + // an untagged entry — exercises the lazy-build branches: + // - !k continue (untagged) + // - !arr first set + // - arr already exists, just push + group.info('a1', { snapshot: { name: 'A' } }); + group.info('a2', { snapshot: { name: 'A' } }); + group.info('plain'); // no snapshot meta + + // First snapshotLogs call triggers lazy build + expect(logger.snapshotLogs({ name: 'A' }).map(l => l.message)).toEqual(['a1', 'a2']); + }); - logger.loglevel('debug'); - try { - logger.measure('step', 'test1', meta, callback); - } catch (e) { - expect(e).toEqual(error); - } - expect(helpers.stdout).toEqual([ - jasmine.stringContaining(`[${colors.magenta('percy:test')}] abcd`) - ]); - const mlog = logger.instance.query((msg => msg.debug === 'timer'))[0]; - expect(mlog.meta.errorMsg).toEqual('Error'); - expect(mlog.meta.errorStack).toEqual(jasmine.stringContaining('Error: Error')); + it('memory-mode snapshotLogs returns [] for an unknown key after lazy build', () => { + process.env.PERCY_LOGS_IN_MEMORY = '1'; + delete logger.constructor.instance; + + logger('mem:miss').info('m1', { snapshot: { name: 'present' } }); + + // build the index then ask for a key it doesn't contain + expect(logger.snapshotLogs({ name: 'absent' })).toEqual([]); + }); + + it('memory-mode index update — incremental _record path with and without meta', () => { + process.env.PERCY_LOGS_IN_MEMORY = '1'; + delete logger.constructor.instance; + + let group = logger('mem:inc'); + group.info('a1', { snapshot: { name: 'A' } }); + // build the index + expect(logger.snapshotLogs({ name: 'A' }).map(l => l.message)).toEqual(['a1']); + + // incremental updates: existing key, new key, untagged + group.info('a2', { snapshot: { name: 'A' } }); + group.info('b1', { snapshot: { name: 'B' } }); + group.info('untagged'); + + expect(logger.snapshotLogs({ name: 'A' }).map(l => l.message)).toEqual(['a1', 'a2']); + expect(logger.snapshotLogs({ name: 'B' }).map(l => l.message)).toEqual(['b1']); + }); + + it('evictSnapshot drops the cache but disk + retry rescan still surface the entries', () => { + let group = logger('core:snapshot'); + group.info('hi', { snapshot: { testCase: 'tc', name: 'A' } }); + + expect(logger.snapshotLogs({ testCase: 'tc', name: 'A' }).length).toEqual(1); + logger.evictSnapshot({ testCase: 'tc', name: 'A' }); + + // The next snapshotLogs() call after evict triggers the retry-path + // full rescan and recovers the pre-eviction entry. This mirrors + // master's `messages` Set retain-everything behavior. + expect(logger.snapshotLogs({ testCase: 'tc', name: 'A' }).map(l => l.message)) + .toEqual(['hi']); + + // and disk still has it for sendBuildLogs to query + expect(logger.query(() => true).find(e => e.message === 'hi')).toBeDefined(); + }); + + it('flushes the buffer when query is called', () => { + // 5 entries; below the 500 size cap and faster than the 100ms timer. + let group = logger('disk'); + for (let i = 0; i < 5; i++) group.info(`x${i}`); + + // query forces a flush, so entries become visible on disk + let result = logger.query(() => true); + expect(result.length).toEqual(5); + }); + + it('reset() clears state and removes the disk file', () => { + logger('disk').info('temporary'); + logger.query(() => true); + + let files = readdirSync(percyLogsDir).filter(f => f.endsWith('.jsonl')); + expect(files.length).toEqual(1); + let path = join(percyLogsDir, files[0]); + expect(existsSync(path)).toBeTrue(); + + logger.instance.reset(); + expect(existsSync(path)).toBeFalse(); + expect(logger.query(() => true).length).toEqual(0); + }); + + it('survives circular references in meta', () => { + let circular = {}; + circular.self = circular; + logger('disk').error('round and round', { circle: circular }); + + let entries = logger.query(() => true); + expect(entries.length).toEqual(1); + expect(entries[0].meta).toEqual({ unserializable: true }); + }); + + it('preserves snapshot key when meta has circular references', () => { + let circular = {}; + circular.self = circular; + logger('disk').error('boom', { snapshot: { name: 'A' }, circle: circular }); + + // The entry should still route to snapshotLogs even after meta sanitization. + let logs = logger.snapshotLogs({ name: 'A' }); + expect(logs.length).toEqual(1); + expect(logs[0].meta).toEqual({ unserializable: true, snapshot: { name: 'A' } }); + }); + + it('falls back to in-memory mode when PERCY_LOGS_IN_MEMORY=1', () => { + logger.instance.reset(); + process.env.PERCY_LOGS_IN_MEMORY = '1'; + // force a fresh instance so the env is re-read + delete logger.constructor.instance; + + logger('mem').info('no disk for me'); + + let files; + try { files = readdirSync(percyLogsDir).filter(f => f.endsWith('.jsonl')); } catch { files = []; } + expect(files.length).toEqual(0); + + let entries = logger.query(() => true); + expect(entries.length).toEqual(1); + expect(entries[0].message).toEqual('no disk for me'); + }); + + it('snapshotLogs filters from the in-memory Set in memory mode', () => { + logger.instance.reset(); + process.env.PERCY_LOGS_IN_MEMORY = '1'; + delete logger.constructor.instance; + + let group = logger('core:snapshot'); + group.info('A1', { snapshot: { testCase: 'tc', name: 'A' } }); + group.info('B1', { snapshot: { testCase: 'tc', name: 'B' } }); + group.info('untagged'); + + // First call: mode is still 'disk' at entry, _flushSync flips it to memory. + let logsA = logger.snapshotLogs({ testCase: 'tc', name: 'A' }); + expect(logsA.length).toEqual(1); + expect(logsA[0].message).toEqual('A1'); + + // Add another tagged entry — goes directly into the fallback Set now. + group.info('A2', { snapshot: { testCase: 'tc', name: 'A' } }); + + // Second call: mode is 'memory' at entry — covers the top-of-method memory branch. + let logsA2 = logger.snapshotLogs({ testCase: 'tc', name: 'A' }); + expect(logsA2.length).toEqual(2); + + // also covers the top-of-method memory branch in query() + let all = logger.query(() => true); + expect(all.length).toEqual(4); + + // empty meta returns [] + expect(logger.snapshotLogs({}).length).toEqual(0); + }); + + it('falls back to memory when appendFileSync throws', () => { + let calls = 0; + let real = fs.appendFileSync; + let spy = spyOn(fs, 'appendFileSync').and.callFake((...args) => { + calls++; + if (calls === 1) throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }); + return real.apply(fs, args); + }); + + logger('disk').info('first entry'); + // forces a flush, which triggers the appendFileSync failure + let entries = logger.query(() => true); + + expect(spy).toHaveBeenCalled(); + expect(entries.length).toEqual(1); + expect(entries[0].message).toEqual('first entry'); + // after fallback, no disk file should exist + let files = []; + try { files = readdirSync(percyLogsDir); } catch { /* ok */ } + expect(files.filter(f => f.endsWith('.jsonl')).length).toEqual(0); + }); + + it('falls back to memory when mkdirSync throws', () => { + spyOn(fs, 'mkdirSync').and.throwError(Object.assign(new Error('EACCES'), { code: 'EACCES' })); + + logger('disk').info('cannot create dir'); + let entries = logger.query(() => true); + + expect(entries.length).toEqual(1); + expect(entries[0].message).toEqual('cannot create dir'); + }); + + it('drains pre-fallback buffer entries into memory', () => { + spyOn(fs, 'appendFileSync').and.callFake(() => { + throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }); + }); + + let group = logger('disk'); + group.info('one'); + group.info('two'); + group.info('three'); + + let entries = logger.query(() => true); + expect(entries.length).toEqual(3); + expect(entries.map(e => e.message)).toEqual(['one', 'two', 'three']); + }); + + it('reads existing disk content into memory when fallback fires mid-build', () => { + let original = fs.appendFileSync; + let failAfter = 1; + let calls = 0; + spyOn(fs, 'appendFileSync').and.callFake((...args) => { + calls++; + if (calls > failAfter) throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }); + return original.apply(fs, args); + }); + + let group = logger('disk'); + group.info('on disk'); + logger.query(() => true); // flush #1 — succeeds, on disk + + group.info('after fallback'); + let entries = logger.query(() => true); // flush #2 — fails, fallback fires + + expect(entries.length).toEqual(2); + expect(entries.map(e => e.message).sort()).toEqual(['after fallback', 'on disk']); + }); + + it('evictSnapshot with empty meta is a no-op', () => { + logger.instance.evictSnapshot({}); + logger.instance.evictSnapshot(); + expect(logger.query(() => true).length).toEqual(0); + }); + + it('snapshotKey works with only name or only testCase set', () => { + let group = logger('partial'); + group.info('only-name', { snapshot: { name: 'A' } }); + group.info('only-testcase', { snapshot: { testCase: 'tc' } }); + group.info('both', { snapshot: { testCase: 'tc', name: 'A' } }); + + expect(logger.snapshotLogs({ name: 'A' }).length).toEqual(1); + expect(logger.snapshotLogs({ testCase: 'tc' }).length).toEqual(1); + expect(logger.snapshotLogs({ testCase: 'tc', name: 'A' }).length).toEqual(1); + }); + + it('query filter rejects non-matching entries', () => { + let g = logger('disk'); + g.info('keep-me'); + g.info('drop-me'); + let kept = logger.query(e => e.message === 'keep-me'); + expect(kept.length).toEqual(1); + expect(kept[0].message).toEqual('keep-me'); + }); + + it('logger.reset() (public wrapper) clears the logger', () => { + logger('disk').info('temporary'); + expect(logger.query(() => true).length).toEqual(1); + logger.reset(); + expect(logger.query(() => true).length).toEqual(0); + }); + + it('the 100ms timer flushes the buffer on its own', async () => { + logger('timer').info('lazy'); + // Wait past the FLUSH_TIMER_MS window so the timer callback fires + // without us forcing a flush via query(). + await new Promise(r => setTimeout(r, 150)); + // Now query() should find the entry already on disk; the buffer is + // empty, so no extra flush happens here. + expect(logger.query(() => true).length).toEqual(1); + }); + + it('auto-flushes when the buffer hits the entry cap', () => { + let group = logger('cap'); + // FLUSH_AT_ENTRIES = 500. Push more than that to trigger the size-cap flush. + for (let i = 0; i < 510; i++) group.info(`x${i}`); + let entries = logger.query(() => true); + expect(entries.length).toEqual(510); + }); + + it('skips untagged entries while building the snapshot cache from disk', () => { + let group = logger('mix'); + group.info('untagged-1'); + group.info('tagged-1', { snapshot: { name: 'A' } }); + group.info('untagged-2'); + // first snapshotLogs call triggers cache build from disk delta + let logsA = logger.snapshotLogs({ name: 'A' }); + expect(logsA.length).toEqual(1); + expect(logsA[0].message).toEqual('tagged-1'); + }); + + it('disk-fail warning falls back to err.message when no code is present', () => { + let warnSpy = jasmine.createSpy('write'); + let originalStderr = logger.constructor.stderr; + logger.constructor.stderr = { write: warnSpy }; + + spyOn(fs, 'appendFileSync').and.throwError(new Error('something broke')); + + logger('msg').info('one'); + logger.query(() => true); + + logger.constructor.stderr = originalStderr; + expect(warnSpy.calls.allArgs().some(a => /something broke/.test(a[0]))).toBeTrue(); + }); + + it('disk-fail warning shows "unknown" when err has neither code nor message', () => { + let warnSpy = jasmine.createSpy('write'); + let originalStderr = logger.constructor.stderr; + logger.constructor.stderr = { write: warnSpy }; + + // bare object — no .code, no .message — exercises the 'unknown' branch + // eslint-disable-next-line no-throw-literal + spyOn(fs, 'appendFileSync').and.callFake(() => { throw {}; }); + + logger('unk').info('one'); + logger.query(() => true); + + logger.constructor.stderr = originalStderr; + expect(warnSpy.calls.allArgs().some(a => /\(unknown\)/.test(a[0]))).toBeTrue(); + }); + + it('fires the disk-write-failed stderr warning exactly once', () => { + let warnSpy = jasmine.createSpy('write'); + let originalStderr = logger.constructor.stderr; + logger.constructor.stderr = { write: warnSpy }; + + spyOn(fs, 'appendFileSync').and.throwError(Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' })); + + logger('warn1').info('one'); + logger.query(() => true); + logger('warn2').info('two'); + logger.query(() => true); + + logger.constructor.stderr = originalStderr; + expect(warnSpy.calls.allArgs().filter(a => /disk write failed/.test(a[0])).length).toEqual(1); + }); + + it('cleans up the disk file when the process exit hook fires', () => { + logger('exit').info('hello'); + logger.query(() => true); // ensure file exists + let files = readdirSync(percyLogsDir).filter(f => f.endsWith('.jsonl')); + expect(files.length).toEqual(1); + let path = join(percyLogsDir, files[0]); + + // Manually invoke the registered exit listener — simulating process.emit('exit') + // would also fire other test-suite listeners which we don't want. + let listeners = process.listeners('exit'); + for (let listener of listeners.slice(-1)) listener(); + + expect(existsSync(path)).toBeFalse(); + }); + + it('writes the JSONL into the per-pid subdir', () => { + logger('pid').info('one'); + logger.query(() => true); + + let pidDir = join(tmpdir(), 'percy-logs', String(process.pid)); + let files = readdirSync(pidDir).filter(f => f.endsWith('.jsonl')); + expect(files.length).toEqual(1); + // diskPath itself sits under the pid subdir + expect(logger.instance.diskPath.includes(`${sep}${process.pid}${sep}`)).toBeTrue(); + }); + + it('process[Symbol.for(@percy/logger.exitHooksInstalled)] is the dedupe latch', () => { + logger('latch').info('one'); + logger.query(() => true); + expect(process[Symbol.for('@percy/logger.exitHooksInstalled')]).toBeTrue(); + // Active-instance Set holds the live logger, so it survives module reloads + expect(process[Symbol.for('@percy/logger.activeInstances')].has(logger.instance)).toBeTrue(); + }); + + it('exit-hook cleanup iterates every active logger instance', () => { + // The current singleton + a sibling instance held off-thread (we can't + // construct two live PercyLogger via the singleton getter, so we add + // a stub directly to the active set to mirror the multi-instance case). + logger('a').info('one'); + logger.query(() => true); + let firstPath = logger.instance.diskPath; + + let stub = { + diskPath: join(percyLogsDir, '99999-stub.jsonl'), + _flushSync() {}, + _cleanup: logger.constructor.prototype._cleanup + }; + // create the stub's file so we can verify cleanup unlinks it + fs.writeFileSync(stub.diskPath, ''); + process[Symbol.for('@percy/logger.activeInstances')].add(stub); + + let listeners = process.listeners('exit'); + for (let listener of listeners.slice(-1)) listener(); + + expect(existsSync(firstPath)).toBeFalse(); + expect(existsSync(stub.diskPath)).toBeFalse(); + // tidy up our manual injection + process[Symbol.for('@percy/logger.activeInstances')].delete(stub); + }); + + it('rmdir best-effort tolerates a non-empty pid subdir', () => { + logger('rm').info('hi'); + logger.query(() => true); + let diskPath = logger.instance.diskPath; + let pidDir = dirname(diskPath); + // peer file in the same pid subdir + let peer = join(pidDir, 'peer.txt'); + fs.writeFileSync(peer, 'peer'); + + logger.instance.reset(); + + // peer survived because rmdir is best-effort and the dir is non-empty + expect(existsSync(peer)).toBeTrue(); + // our jsonl is gone + expect(existsSync(diskPath)).toBeFalse(); + fs.unlinkSync(peer); + }); + }); + + describe('PERCY_LOGLEVEL', () => { + it('honors the env var on construction', () => { + delete logger.constructor.instance; + process.env.PERCY_LOGLEVEL = 'error'; + expect(logger.loglevel()).toEqual('error'); + delete process.env.PERCY_LOGLEVEL; }); }); });