From f2390c85a3c394ad86c613e39395634d5e4c3c44 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 4 Aug 2026 12:13:23 +0530 Subject: [PATCH 1/2] fix: targeted guidance when the seed-build wait 403s on a write-only token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit percy exec runs with the project's default write-only token, but the seed-build status poll needs a read-capable token — the first poll 403s, and the generic wait-timeout warning ("state: unknown") pointed users at the wrong problem. Classify the auth failure, stop polling (retries can never succeed), and ask for the project's full access token up front, before the head build's snapshots are taken. Co-Authored-By: Claude Fable 5 --- packages/cli-exec/src/baseline.js | 29 ++++++++++++++++++++- packages/cli-exec/test/baseline.test.js | 34 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/cli-exec/src/baseline.js b/packages/cli-exec/src/baseline.js index 71774521c..e33703cf4 100644 --- a/packages/cli-exec/src/baseline.js +++ b/packages/cli-exec/src/baseline.js @@ -124,18 +124,35 @@ export async function findBaselineProvider({ cwd = process.cwd(), log } = {}) { return null; } +// The seed-build status poll needs a READ-capable token, but `percy exec` normally runs with the +// project's default write-only token — the read then 403s (build reads are master/read_only-gated +// server-side). That's a token-choice problem the user can fix, not a transient failure, so it is +// classified here and surfaced as its own sentinel instead of the generic wait-timeout warning. +function isAuthFailure(error) { + let status = error?.response?.statusCode; + if (status === 401 || status === 403) return true; + return /\b(401|403|forbidden|unauthori[sz]ed)\b/i.test(error?.message || ''); +} + // The seed build keeps processing (renders + auto-approval) after finalize. The head build must // not start until it reaches a terminal state — head snapshots select their baseline as they are // processed, and an unapproved seed means the whole first run shows as new instead of diffing. // The timeout matches the pipeline latency budget (~99% of builds finish under 5 minutes) — // a seed of committed screenshots still renders server-side, so first runs can hold for minutes. +// Returns the terminal state, or 'unauthorized' when the token cannot read build status at all +// (every retry would fail identically, so polling stops on the first auth failure). export async function waitForSeedBuild(client, buildId, { log, timeout = 600000, interval = 5000 }) { let deadline = Date.now() + timeout; let state = 'pending'; let polls = 0; for (;;) { - ({ state } = (await client.getBuild(buildId)).data.attributes); + try { + ({ state } = (await client.getBuild(buildId)).data.attributes); + } catch (err) { + if (isAuthFailure(err)) return 'unauthorized'; + throw err; + } if (state !== 'pending' && state !== 'processing') return state; if (Date.now() >= deadline) return state; // A visible heartbeat every ~30s so a multi-minute first-run hold doesn't look like a hang. @@ -228,6 +245,16 @@ export async function maybeSeedBaseline(percy, provider, { log, waitTimeout, wai if (state === 'finished') { log.info(`Baseline established from ${seeded}/${baselines.length} committed snapshot(s) ` + 'and auto-approved — this run diffs against it.'); + } else if (state === 'unauthorized') { + // Token-choice problem, not a transient one: build reads need a read-capable token, so + // with the default write-only token Percy cannot hold the run until the baseline is + // ready. Ask for the full access token up front — this fires BEFORE the head build's + // snapshots are taken, while switching tokens can still save the first run. + log.warn(`Uploaded ${seeded}/${baselines.length} baseline snapshot(s), but this token ` + + 'cannot read build status, so Percy cannot wait for your baseline to finish before ' + + 'tests start. Use your project\'s FULL ACCESS token as PERCY_TOKEN for this first run ' + + '(Project settings → Tokens). If snapshots in this run appear as new instead of ' + + 'diffing, approve build #1 in the dashboard.'); } else { log.warn(`Baseline build did not finish processing in time (state: ${state || 'unknown'}) — ` + 'snapshots in this run may show as new instead of diffing against the baseline'); diff --git a/packages/cli-exec/test/baseline.test.js b/packages/cli-exec/test/baseline.test.js index 419452c02..2fbdfacca 100644 --- a/packages/cli-exec/test/baseline.test.js +++ b/packages/cli-exec/test/baseline.test.js @@ -5,6 +5,7 @@ import { findBaselineProvider, maybeSeedBaseline, uploadBaselines, + waitForSeedBuild, sanitizePath, sanitizeDirentName } from '../src/baseline.js'; @@ -146,6 +147,39 @@ describe('exec baseline seeding', () => { expect(log.entries.warn.join('\n')).toContain('did not finish processing in time'); }); + it('asks for the full access token when the wait 403s (write-only token)', async () => { + // `percy exec` normally runs with the write-only token, which cannot read build status — + // the poll 403s. That's a fixable token choice, so the user gets targeted guidance BEFORE + // their tests run instead of the generic wait-timeout warning. + let polls = 0; + let client = fakeClient(); + client.getBuild = async () => { + polls += 1; + throw Object.assign(new Error('403 Forbidden'), { response: { statusCode: 403 } }); + }; + let log = fakeLog(); + let provider = { discoverBaselines: async () => ({ baselines: BASELINES }) }; + + let seeded = await maybeSeedBaseline({ client, projectType: 'web' }, provider, { log }); + + expect(seeded).toBe(true); + // Auth failures never resolve on retry — the wait must stop after the first poll. + expect(polls).toBe(1); + let warned = log.entries.warn.join('\n'); + expect(warned).toContain('FULL ACCESS token'); + expect(warned).toContain('cannot read build status'); + expect(warned).not.toContain('did not finish processing in time'); + }); + + it('classifies auth failures from the message when no response object is attached', async () => { + let client = fakeClient(); + client.getBuild = async () => { throw new Error('401 Unauthorized'); }; + + let state = await waitForSeedBuild(client, 'seed-build-1', { log: fakeLog() }); + + expect(state).toBe('unauthorized'); + }); + it('abandons the seed when the API hands back a non-first build (pre-candidate API)', async () => { let client = fakeClient({ buildNumber: 3 }); let log = fakeLog(); From 2d8129d9bf14b8502d4dd547c634c3451c4ccf97 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 4 Aug 2026 13:06:23 +0530 Subject: [PATCH 2/2] test: cover the message-less error branch in isAuthFailure (coverage gate) --- packages/cli-exec/src/baseline.js | 4 ++-- packages/cli-exec/test/baseline.test.js | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli-exec/src/baseline.js b/packages/cli-exec/src/baseline.js index e33703cf4..c3b29d042 100644 --- a/packages/cli-exec/src/baseline.js +++ b/packages/cli-exec/src/baseline.js @@ -129,9 +129,9 @@ export async function findBaselineProvider({ cwd = process.cwd(), log } = {}) { // server-side). That's a token-choice problem the user can fix, not a transient failure, so it is // classified here and surfaced as its own sentinel instead of the generic wait-timeout warning. function isAuthFailure(error) { - let status = error?.response?.statusCode; + let status = error.response?.statusCode; if (status === 401 || status === 403) return true; - return /\b(401|403|forbidden|unauthori[sz]ed)\b/i.test(error?.message || ''); + return /\b(401|403|forbidden|unauthori[sz]ed)\b/i.test(error.message || ''); } // The seed build keeps processing (renders + auto-approval) after finalize. The head build must diff --git a/packages/cli-exec/test/baseline.test.js b/packages/cli-exec/test/baseline.test.js index 2fbdfacca..9616d6b22 100644 --- a/packages/cli-exec/test/baseline.test.js +++ b/packages/cli-exec/test/baseline.test.js @@ -180,6 +180,14 @@ describe('exec baseline seeding', () => { expect(state).toBe('unauthorized'); }); + it('a message-less non-auth error keeps the generic degrade path', async () => { + let client = fakeClient(); + client.getBuild = async () => { throw new Error(); }; + + await expectAsync(waitForSeedBuild(client, 'seed-build-1', { log: fakeLog() })) + .toBeRejected(); + }); + it('abandons the seed when the API hands back a non-first build (pre-candidate API)', async () => { let client = fakeClient({ buildNumber: 3 }); let log = fakeLog();