Skip to content

Commit a23b5ff

Browse files
Shivanshu-07claude
andauthored
fix(core): [PER-9706] sync Automate snapshots fail with "upload is not async iterable" (#2314)
* fix(core): stop async-iterating percy.upload() in sync relay routes percy.upload() is the generatePromise-wrapped method and returns a Promise, not an async iterable. The sync branches of /percy/comparison, /percy/maestro-screenshot and /percy/automateScreenshot were changed (while fixing Maestro sync) to `for await (const _ of percy.upload(...))`, which throws "upload is not async iterable". SDKs surface this as {"error":"upload is not async iterable"} on every sync snapshot, regressing Percy-on-Automate sync (sync: true) on @percy/cli 1.32.0-1.32.2. generatePromise already drives the underlying async generator to completion, so calling percy.upload() inside the Promise executor enqueues #snapshots and lets the sync queue resolve/reject the attached callback. Revert all three sync routes to that proven 1.31.8 pattern (the raw generator remains available at percy.yield.upload() if direct iteration is ever needed). The original drain-canary tests passed only because they mocked percy.upload to return an async generator, contradicting its real shape. Replace them with regression tests that model the real return shape (a Promise that resolves the callback asynchronously), so async-iterating the return value would fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): guard sync upload against generator errors and strengthen tests Address PR review on the sync relay fix: - Add a trailing .catch(reject) to percy.upload() at all three sync routes (/percy/comparison, /percy/maestro-screenshot, /percy/automateScreenshot) so a generator error that bypasses the sync-queue callback (e.g. a throw before the queue task runs) is surfaced as data.error via handleSyncJob instead of leaking an unhandled rejection and hanging the request. - Assert percy.client.getComparisonDetails was called in the three sync regression tests, proving handleSyncJob ran to completion rather than the request resolving early. - Add a /percy/comparison test that a rejected upload Promise (no callback invoked) surfaces as data.error, directly covering the new .catch(reject). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c1e8904 commit a23b5ff

2 files changed

Lines changed: 73 additions & 62 deletions

File tree

packages/core/src/api.js

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -385,13 +385,16 @@ export function createPercyServer(percy, port) {
385385
.route('post', '/percy/comparison', async (req, res) => {
386386
let data;
387387
if (percy.syncMode(req.body)) {
388-
// percy.upload returns an async generator that must be drained for #snapshots.push to run.
388+
// percy.upload() is the generatePromise-wrapped method: calling it drives the
389+
// underlying async generator to completion (enqueuing #snapshots) and the sync
390+
// queue resolves/rejects the attached callback. Do NOT `for await` the return
391+
// value — it is a Promise, not an async iterable. The raw generator lives at
392+
// percy.yield.upload() if direct iteration is ever needed. The trailing
393+
// .catch(reject) surfaces generator errors that bypass the sync-queue callback
394+
// (e.g. a throw before the queue task runs) instead of leaking an unhandled
395+
// rejection and hanging the request.
389396
const snapshotPromise = new Promise((resolve, reject) => {
390-
const upload = percy.upload(req.body, { resolve, reject }, 'app');
391-
(async () => {
392-
// eslint-disable-next-line no-unused-vars
393-
try { for await (const _ of upload) { /* drain */ } } catch (e) { reject(e); }
394-
})();
397+
percy.upload(req.body, { resolve, reject }, 'app').catch(reject);
395398
});
396399
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
397400
} else {
@@ -905,14 +908,11 @@ export function createPercyServer(percy, port) {
905908

906909
let data;
907910
if (percy.syncMode(payload)) {
908-
// percy.upload returns an async generator that must be drained for #snapshots.push to run.
909-
// See docs/solutions/best-practices/2026-05-20-maestro-sync-promise-bug-investigation.md.
911+
// See the /percy/comparison route: percy.upload() is the Promise-wrapped method;
912+
// calling it drives the generator and the sync queue resolves/rejects the callback.
913+
// The .catch(reject) surfaces generator errors that bypass that callback.
910914
const snapshotPromise = new Promise((resolve, reject) => {
911-
const upload = percy.upload(payload, { resolve, reject }, 'app');
912-
(async () => {
913-
// eslint-disable-next-line no-unused-vars
914-
try { for await (const _ of upload) { /* drain */ } } catch (e) { reject(e); }
915-
})();
915+
percy.upload(payload, { resolve, reject }, 'app').catch(reject);
916916
});
917917
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
918918
return res.json(200, { success: true, data });
@@ -946,13 +946,11 @@ export function createPercyServer(percy, port) {
946946
let comparisonData = await WebdriverUtils.captureScreenshot(req.body);
947947

948948
if (percy.syncMode(comparisonData)) {
949-
// percy.upload returns an async generator that must be drained for #snapshots.push to run.
949+
// See the /percy/comparison route: percy.upload() is the Promise-wrapped method;
950+
// calling it drives the generator and the sync queue resolves/rejects the callback.
951+
// The .catch(reject) surfaces generator errors that bypass that callback.
950952
const snapshotPromise = new Promise((resolve, reject) => {
951-
const upload = percy.upload(comparisonData, { resolve, reject }, 'automate');
952-
(async () => {
953-
// eslint-disable-next-line no-unused-vars
954-
try { for await (const _ of upload) { /* drain */ } } catch (e) { reject(e); }
955-
})();
953+
percy.upload(comparisonData, { resolve, reject }, 'automate').catch(reject);
956954
});
957955
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
958956
} else {

packages/core/test/api.test.js

Lines changed: 56 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -336,31 +336,50 @@ describe('API Server', () => {
336336
expect(percy.client.getComparisonDetails).toHaveBeenCalled();
337337
});
338338

339-
// Cross-consumer drain canary for /percy/comparison. Mirrors the maestro-screenshot
340-
// canary at the bottom of this file. See docs/solutions/best-practices/
341-
// 2026-05-20-maestro-sync-promise-bug-investigation.md.
342-
it('/comparison sync mode: drains the upload generator (real return shape, no mock)', async () => {
343-
let iterCount = 0;
339+
// Regression: percy.upload() is the generatePromise-wrapped method and returns a Promise,
340+
// not an async iterable. The relay must drive it and let the sync queue resolve the
341+
// attached callback — it must never `for await` the return value (that throws
342+
// "upload is not async iterable"). Modelled with the real shape: a Promise return whose
343+
// callback is resolved asynchronously, so a `for await` over it would reject first.
344+
it('/comparison sync mode: resolves via the upload callback, not by iterating the return value', async () => {
344345
spyOn(percy.client, 'getComparisonDetails').and.returnValue(getSnapshotDetailsResponse);
345346
spyOn(percy, 'upload').and.callFake((_, callback) => {
346-
return (async function*() {
347-
iterCount++;
348-
callback.resolve();
349-
yield;
350-
})();
347+
let promise = Promise.resolve();
348+
promise.then(() => callback.resolve());
349+
return promise;
351350
});
352351
await percy.start();
353352

354353
await expectAsync(request('/percy/comparison', {
355354
method: 'POST',
356355
body: {
357-
name: 'Drain canary',
356+
name: 'Sync regression',
358357
sync: true,
359358
tag: { name: 'Tag', osName: 'OS', osVersion: '1', width: 1, height: 1, orientation: 'portrait' }
360359
}
361360
})).toBeResolvedTo(jasmine.objectContaining({ data: getSnapshotDetailsResponse }));
362361

363-
expect(iterCount).toBeGreaterThan(0);
362+
expect(percy.upload).toHaveBeenCalledOnceWith(
363+
jasmine.objectContaining({ name: 'Sync regression' }), jasmine.objectContaining({}), 'app');
364+
// Proves handleSyncJob ran to completion rather than the request resolving early.
365+
expect(percy.client.getComparisonDetails).toHaveBeenCalled();
366+
});
367+
368+
// A generator-level failure that bypasses the sync-queue callback (the upload Promise
369+
// rejects without resolve/reject being invoked) must surface as data.error via the
370+
// route's .catch(reject), not hang the request.
371+
it('/comparison sync mode: surfaces a rejected upload Promise as data.error', async () => {
372+
spyOn(percy, 'upload').and.callFake(() => Promise.reject(new Error('generator boom')));
373+
await percy.start();
374+
375+
await expectAsync(request('/percy/comparison', {
376+
method: 'POST',
377+
body: {
378+
name: 'Sync reject',
379+
sync: true,
380+
tag: { name: 'Tag', osName: 'OS', osVersion: '1', width: 1, height: 1, orientation: 'portrait' }
381+
}
382+
})).toBeResolvedTo(jasmine.objectContaining({ data: { error: 'generator boom' } }));
364383
});
365384

366385
it('includes links in the /comparison endpoint response', async () => {
@@ -607,33 +626,32 @@ describe('API Server', () => {
607626
resolve();
608627
});
609628

610-
// Cross-consumer drain canary for /percy/automateScreenshot. Mirrors the
611-
// maestro-screenshot and /comparison canaries elsewhere in this file. See
612-
// docs/solutions/best-practices/2026-05-20-maestro-sync-promise-bug-investigation.md.
613-
it('/automateScreenshot sync mode: drains the upload generator (real return shape, no mock)', async () => {
614-
let iterCount = 0;
629+
// Regression mirror of the /comparison case for the Percy-on-Automate sync route:
630+
// percy.upload() returns a Promise, so the relay must resolve through the sync callback
631+
// rather than `for await`-ing the return value ("upload is not async iterable").
632+
it('/automateScreenshot sync mode: resolves via the upload callback, not by iterating the return value', async () => {
615633
spyOn(percy.client, 'getComparisonDetails').and.returnValue(getSnapshotDetailsResponse);
616634
spyOn(WebdriverUtils, 'captureScreenshot').and.returnValue({ sync: true });
617635
spyOn(percy, 'upload').and.callFake((_, callback) => {
618-
return (async function*() {
619-
iterCount++;
620-
callback.resolve();
621-
yield;
622-
})();
636+
let promise = Promise.resolve();
637+
promise.then(() => callback.resolve());
638+
return promise;
623639
});
624640
await percy.start();
625641

626642
await expectAsync(request('/percy/automateScreenshot', {
627643
method: 'post',
628644
body: {
629-
name: 'Drain canary',
645+
name: 'Sync regression',
630646
client_info: 'client',
631647
environment_info: 'environment',
632648
options: { sync: true }
633649
}
634650
})).toBeResolvedTo(jasmine.objectContaining({ data: getSnapshotDetailsResponse }));
635651

636-
expect(iterCount).toBeGreaterThan(0);
652+
expect(percy.upload).toHaveBeenCalledOnceWith({ sync: true }, jasmine.objectContaining({}), 'automate');
653+
// Proves handleSyncJob ran to completion rather than the request resolving early.
654+
expect(percy.client.getComparisonDetails).toHaveBeenCalled();
637655
});
638656

639657
it('has a /events endpoint that calls #sendBuildEvents() async with provided options with clientInfo present', async () => {
@@ -1568,11 +1586,9 @@ describe('API Server', () => {
15681586
expect(payload.sync).toBe(true);
15691587
});
15701588

1571-
// Sync mode bug fix coverage — see docs/solutions/best-practices/
1572-
// 2026-05-20-maestro-sync-promise-bug-investigation.md.
1573-
// Before the fix, the relay's `new Promise(executor => percy.upload(...))`
1574-
// returned an async generator that was never iterated, so #snapshots.push
1575-
// never ran and the promise hung forever. The fix drains the generator.
1589+
// Sync mode: a rejected upload is surfaced as data.error in a 200 response. The relay
1590+
// wires the sync queue's reject to the snapshot promise, which handleSyncJob converts
1591+
// into { error } rather than failing the request.
15761592
it('sync mode: surfaces upload reject error as data.error (200 with error field)', async () => {
15771593
spyOn(percy, 'upload').and.callFake((_, callback) => callback.reject(new Error('boom')));
15781594
await percy.start();
@@ -1587,19 +1603,15 @@ describe('API Server', () => {
15871603
}));
15881604
});
15891605

1590-
it('sync mode: drains the upload generator (real percy.upload return shape, no mock)', async () => {
1591-
// Canary for the structural bug: spy on percy.upload but have it return a real
1592-
// async generator-shaped object that records whether it gets iterated.
1593-
// Before the fix, this iteration count would stay at 0 and the test would time out.
1594-
let iterCount = 0;
1606+
// Regression mirror of the /comparison and /automateScreenshot cases: percy.upload()
1607+
// returns a Promise, so the relay must resolve through the sync callback rather than
1608+
// `for await`-ing the return value ("upload is not async iterable").
1609+
it('sync mode: resolves via the upload callback, not by iterating the return value', async () => {
15951610
spyOn(percy.client, 'getComparisonDetails').and.returnValue(getSnapshotDetailsResponse);
15961611
spyOn(percy, 'upload').and.callFake((options, callback) => {
1597-
return (async function*() {
1598-
iterCount++;
1599-
// Simulate the queue-task path: the syncQueue would invoke callback.resolve.
1600-
callback.resolve();
1601-
yield;
1602-
})();
1612+
let promise = Promise.resolve();
1613+
promise.then(() => callback.resolve());
1614+
return promise;
16031615
});
16041616
await percy.start();
16051617

@@ -1610,9 +1622,10 @@ describe('API Server', () => {
16101622
sync: true
16111623
})).toBeResolvedTo(jasmine.objectContaining({ data: getSnapshotDetailsResponse }));
16121624

1613-
// Iteration count > 0 proves the relay drained the generator (vs the old
1614-
// bug where the generator was discarded).
1615-
expect(iterCount).toBeGreaterThan(0);
1625+
expect(percy.upload).toHaveBeenCalledOnceWith(
1626+
jasmine.objectContaining({ sync: true }), jasmine.objectContaining({}), 'app');
1627+
// Proves handleSyncJob ran to completion rather than the request resolving early.
1628+
expect(percy.client.getComparisonDetails).toHaveBeenCalled();
16161629
});
16171630

16181631
it('returns 404 when the screenshot file is missing', async () => {

0 commit comments

Comments
 (0)