From 7a0824a8214acc26310643505a3024fea02dba36 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 13:45:07 +0530 Subject: [PATCH 1/8] Reject sync uploads once the build has failed After a build-level 422 the snapshots queue closes itself, but sync callers were never settled: percy.upload() didn't check build.error, and the queue error handler returned early on QueueClosedError/AbortError without calling the sync reject. The SDK request (e.g. automateScreenshot) then hung until the test framework's own timeout. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/percy.js | 2 + packages/core/src/snapshot.js | 8 ++- packages/core/test/percy.test.js | 86 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 05a535a2d..fd428267e 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -706,6 +706,8 @@ export class Percy { upload(options, callback = null, screenshotFlow = null) { if (this.readyState !== 1) { throw new Error('Not running'); + } else if (this.build?.error) { + throw new Error(this.build.error); } else if (Array.isArray(options)) { return yieldAll(options.map(o => this.yield.upload(o))); } diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index de04f2118..36456c3f3 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -555,8 +555,12 @@ export function createSnapshotsQueue(percy) { let result = { ...snapshot, error }; let { name, meta } = snapshot; - if (error.name === 'QueueClosedError') return result; - if (error.name === 'AbortError') return result; + // a closed or aborted queue never runs the task, so settle any sync caller + // here or the SDK request waiting on it hangs until its own timeout + if (error.name === 'QueueClosedError' || error.name === 'AbortError') { + if (snapshot.sync) snapshot.reject?.(build?.error ? new Error(build.error) : error); + return result; + } let failed = error.response?.statusCode === 422 && ( error.response.body.errors.find(e => ( diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 262d323c3..1048da403 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1305,6 +1305,92 @@ describe('Percy', () => { ]); }); + it('rejects sync uploads once the build has failed upstream', async () => { + api.reply('/snapshots/4567/comparisons', () => [422, { + errors: [ + { detail: 'Cannot create snapshot in failed builds' }, + { detail: 'Build has failed', source: { pointer: '/data/attributes/build' } } + ] + }]); + + await percy.start(); + + let upload = name => new Promise((resolve, reject) => { + percy.upload({ + name, + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + }, { resolve, reject }).catch(reject); + }); + + // the upload the API rejects, then one sent after the build has failed + await expectAsync(upload('Snapshot 1')).toBeRejectedWithError('Build has failed'); + await expectAsync(upload('Snapshot 2')).toBeRejectedWithError('Build has failed'); + }); + + it('rejects queued sync uploads when the build fails while they wait', async () => { + api.reply('/snapshots/4567/comparisons', req => { + // hold the failure so the other uploads queue behind it + req.delay = 500; + return [422, { + errors: [ + { detail: 'Build has failed', source: { pointer: '/data/attributes/build' } } + ] + }]; + }); + + await percy.start(); + + let upload = (name, callback = true) => new Promise((resolve, reject) => { + percy.upload({ + name, + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + }, callback ? { resolve, reject } : null).then(resolve, reject); + }); + + // the next queued upload starts before the failed one closes the queue, + // so the first two reach the API and the rest are aborted while queued + let sent = [upload('Snapshot 1'), upload('Snapshot 2')]; + let noCallback = upload('Snapshot 3', false); + let queued = upload('Snapshot 4'); + + await Promise.all([ + ...[...sent, queued].map(u => expectAsync(u).toBeRejectedWithError('Build has failed')), + // a queued sync upload without callbacks is aborted without throwing + expectAsync(noCallback).toBeResolved() + ]); + expect(api.requests['/snapshots/4567/comparisons']).toHaveSize(2); + }); + + it('rejects queued sync uploads when percy is force stopped', async () => { + api.reply('/snapshots/4567/comparisons', req => { + // hold the first upload so the next one stays queued + req.delay = 500; + return [201, { data: { id: '891011' } }]; + }); + + await percy.start(); + + let upload = name => new Promise((resolve, reject) => { + percy.upload({ + name, + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + }, { resolve, reject }).catch(reject); + }); + + upload('Snapshot 1').catch(() => {}); + let queued = upload('Snapshot 2'); + + let aborted = expectAsync(queued).toBeRejectedWithError(/abort/i); + await percy.stop(true); + await aborted; + }); + it('should raise warning in case of upload command with sync', async () => { percy = new Percy({ token: 'PERCY_TOKEN', From f37066f3c5ffd4eac9a698189c9b34ee739a0733 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 14:54:40 +0530 Subject: [PATCH 2/8] Only fail sync uploads early once the build has failed Non-sync uploads keep their previous behaviour after a build failure, as CodeRabbit pointed out. Adds a regression spec for the non-sync path. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/percy.js | 5 +++-- packages/core/test/percy.test.js | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index fd428267e..57e699f8a 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -706,10 +706,11 @@ export class Percy { upload(options, callback = null, screenshotFlow = null) { if (this.readyState !== 1) { throw new Error('Not running'); - } else if (this.build?.error) { - throw new Error(this.build.error); } else if (Array.isArray(options)) { return yieldAll(options.map(o => this.yield.upload(o))); + } else if (this.build?.error && this.syncMode(options)) { + // a sync caller waits on the result, so fail it now instead of queueing it + throw new Error(this.build.error); } // validate comparison uploads and warn about any errors diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 1048da403..60b625edf 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1329,6 +1329,26 @@ describe('Percy', () => { await expectAsync(upload('Snapshot 2')).toBeRejectedWithError('Build has failed'); }); + it('does not reject non-sync uploads once the build has failed upstream', async () => { + api.reply('/snapshots/4567/comparisons', () => [422, { + errors: [ + { detail: 'Build has failed', source: { pointer: '/data/attributes/build' } } + ] + }]); + + await percy.start(); + + let upload = name => percy.upload({ + name, + tag: { name: 'device' }, + tiles: [{ content: 'foo' }] + }); + + await expectAsync(upload('Snapshot 1')).toBeResolved(); + expect(percy.build.error).toEqual('Build has failed'); + await expectAsync(upload('Snapshot 2')).toBeResolved(); + }); + it('rejects queued sync uploads when the build fails while they wait', async () => { api.reply('/snapshots/4567/comparisons', req => { // hold the failure so the other uploads queue behind it From 0a9b2153cde821d137f53d98358a320f1982c0cd Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 16:30:01 +0530 Subject: [PATCH 3/8] Harden sync rejection: no early throw without callbacks, no unhandled extras - only throw early from upload() when a sync caller passed callbacks to settle - swallow rejections of sync promises nobody awaits (additional snapshots) - reject cancelled sync uploads with a message that says what happened - loosen the queued-upload request count so it doesn't depend on dequeue timing Co-Authored-By: Claude Opus 5.5 --- packages/core/src/percy.js | 5 ++++- packages/core/src/snapshot.js | 4 ++-- packages/core/test/percy.test.js | 16 ++++++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 57e699f8a..3ccdfefba 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -691,6 +691,9 @@ export class Percy { snapshotPromise[snapshot.name] = new Promise((resolve, reject) => { Object.assign(snapshot, { resolve, reject }); }); + // only the requested snapshot's promise is awaited, so a rejected extra + // (additional snapshots, array bodies) must not surface as unhandled + snapshotPromise[snapshot.name].catch(() => {}); } // push each finished snapshot to the snapshots queue this.#snapshots.push(snapshot); @@ -708,7 +711,7 @@ export class Percy { throw new Error('Not running'); } else if (Array.isArray(options)) { return yieldAll(options.map(o => this.yield.upload(o))); - } else if (this.build?.error && this.syncMode(options)) { + } else if (this.build?.error && callback && this.syncMode(options)) { // a sync caller waits on the result, so fail it now instead of queueing it throw new Error(this.build.error); } diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 36456c3f3..459112f20 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -558,7 +558,7 @@ export function createSnapshotsQueue(percy) { // a closed or aborted queue never runs the task, so settle any sync caller // here or the SDK request waiting on it hangs until its own timeout if (error.name === 'QueueClosedError' || error.name === 'AbortError') { - if (snapshot.sync) snapshot.reject?.(build?.error ? new Error(build.error) : error); + if (snapshot.sync) snapshot.reject?.(new Error(build?.error || `Snapshot upload was cancelled: ${error.message}`)); return result; } @@ -594,7 +594,7 @@ export function createSnapshotsQueue(percy) { { message: error?.message, meta } ]; await percy.suggestionsForFix(snapshotErrors, { snapshotLevel: true, snapshotName: name }); - if (snapshot.sync) snapshot.reject(error); + if (snapshot.sync) snapshot.reject?.(error); return result; }); } diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 60b625edf..dcaae1805 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1347,6 +1347,14 @@ describe('Percy', () => { await expectAsync(upload('Snapshot 1')).toBeResolved(); expect(percy.build.error).toEqual('Build has failed'); await expectAsync(upload('Snapshot 2')).toBeResolved(); + + // sync without callbacks (e.g. the comparison upload route) nobody is waiting on + await expectAsync(percy.upload({ + name: 'Snapshot 3', + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + })).toBeResolved(); }); it('rejects queued sync uploads when the build fails while they wait', async () => { @@ -1371,8 +1379,8 @@ describe('Percy', () => { }, callback ? { resolve, reject } : null).then(resolve, reject); }); - // the next queued upload starts before the failed one closes the queue, - // so the first two reach the API and the rest are aborted while queued + // whichever uploads are still queued when the failure closes the queue are + // aborted, so they reject with the build error without reaching the API let sent = [upload('Snapshot 1'), upload('Snapshot 2')]; let noCallback = upload('Snapshot 3', false); let queued = upload('Snapshot 4'); @@ -1382,7 +1390,7 @@ describe('Percy', () => { // a queued sync upload without callbacks is aborted without throwing expectAsync(noCallback).toBeResolved() ]); - expect(api.requests['/snapshots/4567/comparisons']).toHaveSize(2); + expect(api.requests['/snapshots/4567/comparisons'].length).toBeLessThan(4); }); it('rejects queued sync uploads when percy is force stopped', async () => { @@ -1406,7 +1414,7 @@ describe('Percy', () => { upload('Snapshot 1').catch(() => {}); let queued = upload('Snapshot 2'); - let aborted = expectAsync(queued).toBeRejectedWithError(/abort/i); + let aborted = expectAsync(queued).toBeRejectedWithError(/^Snapshot upload was cancelled: .*abort/i); await percy.stop(true); await aborted; }); From cfe822faeda7a8801dd1d76ac6d87a03ed9f695b Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 16:36:24 +0530 Subject: [PATCH 4/8] Reject sync uploads the API ignores as duplicates The duplicate-snapshot branch returned without settling a sync caller, so the SDK request hung like the build-failure case. Also name the error when a sync upload is cancelled, since QueueClosedError has no message. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/snapshot.js | 6 ++++-- packages/core/test/percy.test.js | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 459112f20..050234691 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -558,7 +558,7 @@ export function createSnapshotsQueue(percy) { // a closed or aborted queue never runs the task, so settle any sync caller // here or the SDK request waiting on it hangs until its own timeout if (error.name === 'QueueClosedError' || error.name === 'AbortError') { - if (snapshot.sync) snapshot.reject?.(new Error(build?.error || `Snapshot upload was cancelled: ${error.message}`)); + if (snapshot.sync) snapshot.reject?.(new Error(build?.error || `Snapshot upload was cancelled (${error.name})`)); return result; } @@ -576,12 +576,14 @@ export function createSnapshotsQueue(percy) { let errors = error.response?.body?.errors; let duplicate = errors?.length > 1 && errors[1].detail.includes('must be unique'); if (duplicate) { + let errMsg = `Ignored duplicate snapshot. ${errors[1].detail}`; if (process.env.PERCY_IGNORE_DUPLICATES !== 'true') { - let errMsg = `Ignored duplicate snapshot. ${errors[1].detail}`; percy.log.warn(errMsg, meta); await percy.suggestionsForFix(errMsg, { snapshotLevel: true, snapshotName: name }); } + // no comparison was created, so a sync caller has nothing to wait for + if (snapshot.sync) snapshot.reject?.(new Error(errMsg)); return result; } diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index dcaae1805..29cc43001 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1329,6 +1329,26 @@ describe('Percy', () => { await expectAsync(upload('Snapshot 2')).toBeRejectedWithError('Build has failed'); }); + it('rejects sync uploads the API ignores as duplicates', async () => { + api.reply('/snapshots/4567/comparisons', () => [400, { + errors: [{ status: 'bad_request' }, + { detail: 'The name of each snapshot must be unique ...' }] + }]); + + await percy.start(); + + await expectAsync(new Promise((resolve, reject) => { + percy.upload({ + name: 'Snapshot', + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + }, { resolve, reject }).catch(reject); + })).toBeRejectedWithError( + 'Ignored duplicate snapshot. The name of each snapshot must be unique ...' + ); + }); + it('does not reject non-sync uploads once the build has failed upstream', async () => { api.reply('/snapshots/4567/comparisons', () => [422, { errors: [ @@ -1414,7 +1434,7 @@ describe('Percy', () => { upload('Snapshot 1').catch(() => {}); let queued = upload('Snapshot 2'); - let aborted = expectAsync(queued).toBeRejectedWithError(/^Snapshot upload was cancelled: .*abort/i); + let aborted = expectAsync(queued).toBeRejectedWithError('Snapshot upload was cancelled (AbortError)'); await percy.stop(true); await aborted; }); From df747233866849a71510b66f506be29d73ca9c26 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 16:42:14 +0530 Subject: [PATCH 5/8] Tell sync callers why their upload was cancelled in plain words Replace the internal error class name in the cancel message with a reason the SDK user can act on, and correct the queued-upload test's comments. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/snapshot.js | 8 +++++++- packages/core/test/percy.test.js | 14 +++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 050234691..f5720fd18 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -420,6 +420,12 @@ export async function uploadSnapshotLog(percy, buildId, snapshotId, meta = {}) { } } +// What a sync caller is told when its upload never ran, by queue error name +const CANCELLED_REASONS = { + QueueClosedError: 'Snapshot was not uploaded because Percy is no longer accepting snapshots', + AbortError: 'Snapshot upload was cancelled before it finished, because Percy stopped or a newer snapshot with the same name replaced it' +}; + // Creates a snapshots queue that manages a Percy build and uploads snapshots. export function createSnapshotsQueue(percy) { let { concurrency } = percy.config.discovery; @@ -558,7 +564,7 @@ export function createSnapshotsQueue(percy) { // a closed or aborted queue never runs the task, so settle any sync caller // here or the SDK request waiting on it hangs until its own timeout if (error.name === 'QueueClosedError' || error.name === 'AbortError') { - if (snapshot.sync) snapshot.reject?.(new Error(build?.error || `Snapshot upload was cancelled (${error.name})`)); + if (snapshot.sync) snapshot.reject?.(new Error(build?.error || CANCELLED_REASONS[error.name])); return result; } diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 29cc43001..b4cf1de6b 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1399,17 +1399,19 @@ describe('Percy', () => { }, callback ? { resolve, reject } : null).then(resolve, reject); }); - // whichever uploads are still queued when the failure closes the queue are - // aborted, so they reject with the build error without reaching the API - let sent = [upload('Snapshot 1'), upload('Snapshot 2')]; + // with a concurrency of 1, the first failure closes the queue while the rest + // wait (the next one may already be dequeued by then); every sync caller, + // sent or aborted while queued, must reject with the build error + let failed = [upload('Snapshot 1'), upload('Snapshot 2')]; let noCallback = upload('Snapshot 3', false); let queued = upload('Snapshot 4'); await Promise.all([ - ...[...sent, queued].map(u => expectAsync(u).toBeRejectedWithError('Build has failed')), + ...[...failed, queued].map(u => expectAsync(u).toBeRejectedWithError('Build has failed')), // a queued sync upload without callbacks is aborted without throwing expectAsync(noCallback).toBeResolved() ]); + // at least the last queued uploads never reached the API expect(api.requests['/snapshots/4567/comparisons'].length).toBeLessThan(4); }); @@ -1434,7 +1436,9 @@ describe('Percy', () => { upload('Snapshot 1').catch(() => {}); let queued = upload('Snapshot 2'); - let aborted = expectAsync(queued).toBeRejectedWithError('Snapshot upload was cancelled (AbortError)'); + let aborted = expectAsync(queued).toBeRejectedWithError( + 'Snapshot upload was cancelled before it finished, because Percy stopped or a newer snapshot with the same name replaced it' + ); await percy.stop(true); await aborted; }); From 424fe35f4e4218e7ac6758597e6855b96d2e461c Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 16:48:15 +0530 Subject: [PATCH 6/8] Assert the uploads queued behind the failure never reach the API Co-Authored-By: Claude Opus 5.5 --- packages/core/test/percy.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index b4cf1de6b..2122f7ffd 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1411,8 +1411,8 @@ describe('Percy', () => { // a queued sync upload without callbacks is aborted without throwing expectAsync(noCallback).toBeResolved() ]); - // at least the last queued uploads never reached the API - expect(api.requests['/snapshots/4567/comparisons'].length).toBeLessThan(4); + // the uploads queued behind the first two (Snapshots 3 and 4) never reached the API + expect(api.requests['/snapshots/4567/comparisons'].length).toBeLessThan(3); }); it('rejects queued sync uploads when percy is force stopped', async () => { From 334dc4548b99c9a54b02cef4c6a88a666105f4db Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 17:35:53 +0530 Subject: [PATCH 7/8] Don't queue sync jobs nobody waits on; keep the generic error path as before - a sync upload without callbacks (e.g. the comparison upload route with config sync) pushed a JobData with no resolve/reject, which throws inside the WaitForJob poller and stops polling for every other sync job - restore snapshot.reject(error) on the generic upload error path, so that path behaves exactly as before this PR - one plain cancel message for sync callers when the build has not failed Co-Authored-By: Claude Opus 5.5 --- packages/core/src/snapshot.js | 14 ++++++-------- packages/core/test/percy.test.js | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index f5720fd18..c5c8e9bfd 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -420,11 +420,8 @@ export async function uploadSnapshotLog(percy, buildId, snapshotId, meta = {}) { } } -// What a sync caller is told when its upload never ran, by queue error name -const CANCELLED_REASONS = { - QueueClosedError: 'Snapshot was not uploaded because Percy is no longer accepting snapshots', - AbortError: 'Snapshot upload was cancelled before it finished, because Percy stopped or a newer snapshot with the same name replaced it' -}; +// What a sync caller is told when its upload never ran and the build has not failed +const SYNC_CANCELLED = 'Snapshot upload was cancelled before it finished, because Percy stopped or a newer snapshot with the same name replaced it'; // Creates a snapshots queue that manages a Percy build and uploads snapshots. export function createSnapshotsQueue(percy) { @@ -548,7 +545,8 @@ export function createSnapshotsQueue(percy) { // Pushing to syncQueue, that will check for // snapshot processing status, and will resolve once done - if (snapshot.sync) { + // (only when someone waits on it: a job without callbacks would throw in the poller) + if (snapshot.sync && snapshot.resolve) { percy.log.info(`Waiting for snapshot '${name}' to be completed`, meta); const data = new JobData(response.data.id, null, snapshot.resolve, snapshot.reject); percy.syncQueue.push(data); @@ -564,7 +562,7 @@ export function createSnapshotsQueue(percy) { // a closed or aborted queue never runs the task, so settle any sync caller // here or the SDK request waiting on it hangs until its own timeout if (error.name === 'QueueClosedError' || error.name === 'AbortError') { - if (snapshot.sync) snapshot.reject?.(new Error(build?.error || CANCELLED_REASONS[error.name])); + if (snapshot.sync) snapshot.reject?.(new Error(build?.error || SYNC_CANCELLED)); return result; } @@ -602,7 +600,7 @@ export function createSnapshotsQueue(percy) { { message: error?.message, meta } ]; await percy.suggestionsForFix(snapshotErrors, { snapshotLevel: true, snapshotName: name }); - if (snapshot.sync) snapshot.reject?.(error); + if (snapshot.sync) snapshot.reject(error); return result; }); } diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 2122f7ffd..7b1c79fff 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1305,6 +1305,23 @@ describe('Percy', () => { ]); }); + it('does not wait on sync snapshot comparisons nobody is waiting for', async () => { + await percy.start(); + + // e.g. the comparison upload route, which passes no callbacks + await percy.upload({ + name: 'Snapshot', + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + }); + + await percy.idle(); + expect(api.requests['/comparisons/891011/finalize']).toHaveSize(1); + expect(percy.syncQueue.jobs).toHaveSize(0); + expect(logger.stderr).toEqual([]); + }); + it('rejects sync uploads once the build has failed upstream', async () => { api.reply('/snapshots/4567/comparisons', () => [422, { errors: [ From 43c1d89d1a71289c85aa197cf9e25542bb8c1079 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 22 Oct 2026 19:00:36 +0530 Subject: [PATCH 8/8] Settle duplicate sync uploads before suggestions; don't throw for callback-free sync failures Co-Authored-By: Claude Opus 5.5 --- packages/core/src/snapshot.js | 6 +++--- packages/core/test/percy.test.js | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index c5c8e9bfd..ad3d291f0 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -581,13 +581,13 @@ export function createSnapshotsQueue(percy) { let duplicate = errors?.length > 1 && errors[1].detail.includes('must be unique'); if (duplicate) { let errMsg = `Ignored duplicate snapshot. ${errors[1].detail}`; + // no comparison was created, so a sync caller has nothing to wait for + if (snapshot.sync) snapshot.reject?.(new Error(errMsg)); if (process.env.PERCY_IGNORE_DUPLICATES !== 'true') { percy.log.warn(errMsg, meta); await percy.suggestionsForFix(errMsg, { snapshotLevel: true, snapshotName: name }); } - // no comparison was created, so a sync caller has nothing to wait for - if (snapshot.sync) snapshot.reject?.(new Error(errMsg)); return result; } @@ -600,7 +600,7 @@ export function createSnapshotsQueue(percy) { { message: error?.message, meta } ]; await percy.suggestionsForFix(snapshotErrors, { snapshotLevel: true, snapshotName: name }); - if (snapshot.sync) snapshot.reject(error); + if (snapshot.sync) snapshot.reject?.(error); return result; }); } diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 7b1c79fff..c4393a410 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1322,6 +1322,26 @@ describe('Percy', () => { expect(logger.stderr).toEqual([]); }); + it('does not throw for failed sync uploads nobody is waiting for', async () => { + api.reply('/snapshots/4567/comparisons', () => [422, { + errors: [{ detail: 'Invalid comparison' }] + }]); + + await percy.start(); + + await expectAsync(percy.upload({ + name: 'Snapshot', + tag: { name: 'device' }, + tiles: [{ content: 'foo' }], + sync: true + })).toBeResolved(); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy] Encountered an error uploading snapshot: Snapshot' + ])); + expect(logger.stderr.join()).not.toContain('TypeError'); + }); + it('rejects sync uploads once the build has failed upstream', async () => { api.reply('/snapshots/4567/comparisons', () => [422, { errors: [