Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,25 @@ 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';

if (process.env.PERCY_ORIGINATED_SOURCE) {
source = 'bstack_sdk_created';
} else if (process.env.PERCY_AUTO_ENABLED_GROUP_BUILD === 'true') {
source = 'auto_enabled_group';
// 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 && !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') {
buildSource = 'auto_enabled_group';
}
}

let tagsArr = tagsList(this.labels);
Expand All @@ -342,12 +352,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,
Expand Down
66 changes: 66 additions & 0 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -238,6 +243,67 @@ 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('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: {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/core/test/snapshot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 15 additions & 2 deletions packages/sdk-utils/src/post-comparison.js
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions packages/sdk-utils/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading