From 16df51e24acadbe9953b2cd4c60b59748220c2ed Mon Sep 17 00:00:00 2001 From: Arumulla Sri Ram Date: Thu, 11 Jun 2026 03:04:26 +0530 Subject: [PATCH 1/3] feat(client): additive createBuild params for playwright-dropin baseline seed (Unit 4b) createBuild gains optional parallelNonce/parallelTotal/source so the playwright-dropin baseline-seed path can create a separate parallel build with its own deterministic nonce (engaging the percy-api named-lock dedup) independent of the head build. Fully backward- compatible: params default to null and fall back to the env-derived parallel identity and source; the head/snapshot flow passes none and is unchanged. --- packages/client/src/client.js | 25 +++++++++++++-------- packages/client/test/client.test.js | 34 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 43b21614d..87079bc9f 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -313,15 +313,22 @@ export class PercyClient { // Creates a build with optional build resources. Only one build can be // created at a time per instance so snapshots and build finalization can be // done more seamlessly without manually tracking build ids - async createBuild({ resources = [], projectType, cliStartTime = null } = {}) { + // `parallelNonce`/`parallelTotal` override the env-derived parallel identity so callers can + // create a *separate* parallel build (e.g. the playwright-dropin baseline build) with its own + // deterministic nonce — engaging the percy-api named-lock dedup independently of the head build. + // `source` overrides the env-derived build source tag. These are additive and only used by the + // baseline-seed path; the normal head/snapshot flow passes none of them and behaves unchanged. + async createBuild({ resources = [], projectType, cliStartTime = null, parallelNonce = null, parallelTotal = null, source = null } = {}) { this.log.debug('Creating a new build...'); let visualConfig = parseVisualConfigFromEnv(this.log); - let source = 'user_created'; + let buildSource = source || 'user_created'; - if (process.env.PERCY_ORIGINATED_SOURCE) { - source = 'bstack_sdk_created'; - } else if (process.env.PERCY_AUTO_ENABLED_GROUP_BUILD === 'true') { - source = 'auto_enabled_group'; + if (!source) { + if (process.env.PERCY_ORIGINATED_SOURCE) { + buildSource = 'bstack_sdk_created'; + } else if (process.env.PERCY_AUTO_ENABLED_GROUP_BUILD === 'true') { + buildSource = 'auto_enabled_group'; + } } let tagsArr = tagsList(this.labels); @@ -342,12 +349,12 @@ export class PercyClient { 'commit-committer-email': this.env.git.committerEmail, 'commit-message': this.env.git.message, 'pull-request-number': this.env.pullRequest, - 'parallel-nonce': this.env.parallel.nonce, - 'parallel-total-shards': this.env.parallel.total, + 'parallel-nonce': parallelNonce ?? this.env.parallel.nonce, + 'parallel-total-shards': parallelTotal ?? this.env.parallel.total, partial: this.env.partial, tags: tagsArr, 'cli-start-time': cliStartTime, - source: source, + source: buildSource, 'skip-base-build': this.config.percy?.skipBaseBuild, 'testhub-build-uuid': this.env.testhubBuildUuid, 'testhub-build-run-id': this.env.testhubBuildRunId, diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 84f0e3b0a..e82f3eda7 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -238,6 +238,40 @@ describe('PercyClient', () => { })); }); + it('creates a build with an explicit parallel nonce/total and source override', async () => { + await expectAsync(client.createBuild({ + projectType: 'generic', + parallelNonce: 'baseline-nonce', + parallelTotal: -1, + source: 'playwright-dropin-baseline' + })).toBeResolvedTo({ + data: { + id: '123', + attributes: { + 'build-number': 1, + 'web-url': 'https://percy.io/test/test/123' + } + } + }); + + expect(api.requests['/builds'][0].body.data.attributes) + .toEqual(jasmine.objectContaining({ + 'parallel-nonce': 'baseline-nonce', + 'parallel-total-shards': -1, + source: 'playwright-dropin-baseline' + })); + }); + + it('ignores env-derived source overrides when an explicit source is given', async () => { + process.env.PERCY_ORIGINATED_SOURCE = 'true'; + await expectAsync(client.createBuild({ + source: 'playwright-dropin-baseline' + })).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes.source) + .toEqual('playwright-dropin-baseline'); + }); + it('creates a new build with projectType passed as null', async () => { await expectAsync(client.createBuild({ projectType: null })).toBeResolvedTo({ data: { From 204632702e33517e8c9e3f6eda0bc694a97d92c4 Mon Sep 17 00:00:00 2001 From: Arumulla Sri Ram Date: Thu, 11 Jun 2026 03:36:53 +0530 Subject: [PATCH 2/3] feat(sdk-utils): return per-comparison verdict from postComparison in sync mode (Unit 5b) Additive: when the global .percy.yml snapshot.sync is on, the /percy/comparison handler already returns the sync-cli per-comparison result; postComparison now surfaces response.body.data so the playwright-dropin sync classifier can read the verdict/{error}. Backward-compatible: non-sync responses omit data, fire-and-forget unchanged. 100% coverage. --- packages/sdk-utils/src/post-comparison.js | 17 +++++++++++++++-- packages/sdk-utils/test/index.test.js | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/sdk-utils/src/post-comparison.js b/packages/sdk-utils/src/post-comparison.js index bf682b559..4de61b0cc 100644 --- a/packages/sdk-utils/src/post-comparison.js +++ b/packages/sdk-utils/src/post-comparison.js @@ -1,18 +1,31 @@ import percy from './percy-info.js'; import request from './request.js'; -// Post snapshot data to the CLI snapshot endpoint. If the endpoint responds with a build error, +// Post comparison data to the CLI comparison endpoint. If the endpoint responds with a build error, // indicate that Percy has been disabled. +// +// Sync-assertion mode (Option C): when `options.sync` is set (or the global .percy.yml +// `snapshot.sync` is on), the CLI awaits the per-comparison verdict and returns it in +// `response.body.data` (the sync-cli comparison detail, or `{ error }` on timeout/403/CLI-exit). +// We surface that result directly so SDK callers (the Playwright drop-in) can classify it; the +// fire-and-forget path keeps returning the raw response (backward compatible). export async function postComparison(options, params) { let query = params ? `?${new URLSearchParams(params)}` : ''; - return await request.post(`/percy/comparison${query}`, options).catch(err => { + let response = await request.post(`/percy/comparison${query}`, options).catch(err => { if (err.response?.body?.build?.error) { percy.enabled = false; } else { throw err; } }); + + // In sync mode the server returns the per-comparison verdict under `data`; hand it back so the + // caller can apply its pass/fail classifier. Otherwise return the raw response unchanged. + if (response?.body && Object.prototype.hasOwnProperty.call(response.body, 'data')) { + return response.body.data; + } + return response; } export default postComparison; diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 3bf19be3a..68b5bcae8 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -331,6 +331,20 @@ describe('SDK Utils', () => { body: options }]); }); + + it('returns the per-comparison verdict (response.body.data) in sync mode', async () => { + // In sync mode the CLI returns the comparison detail under `data`; postComparison surfaces it + // directly so the SDK can classify pass/fail (Playwright drop-in Unit 5b). + let detail = { 'snapshot-name': 'home', status: 'success' }; + spyOn(utils.request, 'post').and.callFake(() => Promise.resolve({ body: { success: true, data: detail } })); + await expectAsync(postComparison({ ...options, sync: true })).toBeResolvedTo(detail); + }); + + it('returns the raw response (no body.data) in fire-and-forget mode', async () => { + let response = { body: { success: true } }; + spyOn(utils.request, 'post').and.callFake(() => Promise.resolve(response)); + await expectAsync(postComparison(options)).toBeResolvedTo(response); + }); }); describe('postBuildEvents(options)', () => { From b189f4df2617042360c002e13d13fe891b537d84 Mon Sep 17 00:00:00 2001 From: Arumulla Sri Ram Date: Thu, 11 Jun 2026 11:18:36 +0530 Subject: [PATCH 3/3] fix(playwright-dropin): allow generic projects for BYOS comparisons + PERCY_BUILD_SOURCE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes surfaced by live E2E: - snapshot.js: the 'app' (BYOS) comparison flow now accepts generic (screenshot-type) projects, not just app — @percy/playwright-dropin uploads raw images into a framework- agnostic generic project. Only rendering-type projects (web/scanner/lca) are still rejected. - client.js: createBuild reads PERCY_BUILD_SOURCE so an SDK can tag the head build it doesn't create directly (precedence: explicit param > PERCY_BUILD_SOURCE > legacy env > default). Verified live against percy-api: a generic-project run ingests the comparison and the head build is tagged source=playwright-dropin (build.playwright_dropin? = true). --- packages/client/src/client.js | 7 +++++-- packages/client/test/client.test.js | 32 +++++++++++++++++++++++++++++ packages/core/src/snapshot.js | 5 ++++- packages/core/test/snapshot.test.js | 23 +++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 87079bc9f..a3a8d46bd 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -321,9 +321,12 @@ export class PercyClient { async createBuild({ resources = [], projectType, cliStartTime = null, parallelNonce = null, parallelTotal = null, source = null } = {}) { this.log.debug('Creating a new build...'); let visualConfig = parseVisualConfigFromEnv(this.log); - let buildSource = source || 'user_created'; + // Source precedence: explicit param (baseline-seed path) > PERCY_BUILD_SOURCE env > legacy + // env-derived sources > default. PERCY_BUILD_SOURCE lets an SDK tag the head build it doesn't + // create directly (e.g. @percy/playwright-dropin sets it to 'playwright-dropin'). + let buildSource = source || process.env.PERCY_BUILD_SOURCE || 'user_created'; - if (!source) { + if (!source && !process.env.PERCY_BUILD_SOURCE) { if (process.env.PERCY_ORIGINATED_SOURCE) { buildSource = 'bstack_sdk_created'; } else if (process.env.PERCY_AUTO_ENABLED_GROUP_BUILD === 'true') { diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index e82f3eda7..5928673f9 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -198,9 +198,14 @@ describe('PercyClient', () => { beforeEach(() => { delete process.env.PERCY_AUTO_ENABLED_GROUP_BUILD; delete process.env.PERCY_ORIGINATED_SOURCE; + delete process.env.PERCY_BUILD_SOURCE; delete process.env.PERCY_VISUAL_CONFIG; }); + afterEach(() => { + delete process.env.PERCY_BUILD_SOURCE; + }); + it('creates a new build', async () => { await expectAsync(client.createBuild()).toBeResolvedTo({ data: { @@ -272,6 +277,33 @@ describe('PercyClient', () => { .toEqual('playwright-dropin-baseline'); }); + it('uses PERCY_BUILD_SOURCE as the source when no explicit source is given', async () => { + process.env.PERCY_BUILD_SOURCE = 'playwright-dropin'; + await expectAsync(client.createBuild()).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes.source) + .toEqual('playwright-dropin'); + }); + + it('prefers PERCY_BUILD_SOURCE over the legacy env-derived sources', async () => { + process.env.PERCY_BUILD_SOURCE = 'playwright-dropin'; + process.env.PERCY_ORIGINATED_SOURCE = 'true'; + await expectAsync(client.createBuild()).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes.source) + .toEqual('playwright-dropin'); + }); + + it('prefers an explicit source param over PERCY_BUILD_SOURCE', async () => { + process.env.PERCY_BUILD_SOURCE = 'playwright-dropin'; + await expectAsync(client.createBuild({ + source: 'playwright-dropin-baseline' + })).toBeResolved(); + + expect(api.requests['/builds'][0].body.data.attributes.source) + .toEqual('playwright-dropin-baseline'); + }); + it('creates a new build with projectType passed as null', async () => { await expectAsync(client.createBuild({ projectType: null })).toBeResolvedTo({ data: { diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 8e33750bf..7819559da 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -461,7 +461,10 @@ export function createSnapshotsQueue(percy) { if (percy.client.screenshotFlow === 'automate' && percy.client.buildType !== 'automate') { throw new Error(`Cannot run automate screenshots in ${percy.client.buildType} project. Please use automate project token`); - } else if (percy.client.screenshotFlow === 'app' && percy.client.buildType !== 'app') { + } else if (percy.client.screenshotFlow === 'app' && !['app', 'generic'].includes(percy.client.buildType)) { + // The BYOS comparison flow ('app') also serves generic (screenshot-type) projects — e.g. + // @percy/playwright-dropin uploads raw images into a framework-agnostic generic project. + // Only reject rendering-type projects (web/scanner/lca), which can't ingest raw tiles. throw new Error(`Cannot run App Percy screenshots in ${percy.client.buildType} project. Please use App Percy project token`); } // yield to evaluated snapshot resources diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index 93e8e281e..858859932 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -1952,6 +1952,29 @@ describe('Snapshot', () => { expect(logger.stderr).toEqual(jasmine.arrayContaining([jasmine.stringContaining('[percy] Error: Cannot run App Percy screenshots in automate project. Please use App Percy project token')])); }); + + it('should allow the app (BYOS) comparison flow on a generic project', async () => { + await percy.stop(true); + await api.mock(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + projectType: 'generic' + }); + + percy.client.buildType = 'generic'; + await percy.upload({ + name: 'Snapshot', + external_debug_url: 'localhost', + tag: { name: 'chromium', browserName: 'chromium', width: 1280 }, + tiles: [{ content: 'foo' }] + }, null, 'app'); + + // @percy/playwright-dropin uploads raw images into a generic project via the 'app' flow — + // this must NOT be rejected (the guard only blocks rendering-type projects). + expect(logger.stderr).not.toEqual(jasmine.arrayContaining([jasmine.stringContaining('Cannot run App Percy screenshots in generic project')])); + expect(api.requests['/builds/123/comparisons']).toBeDefined(); + }); }); describe('with percy-css', () => {