Skip to content
Merged
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
29 changes: 28 additions & 1 deletion packages/cli-exec/src/baseline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
Expand Down
42 changes: 42 additions & 0 deletions packages/cli-exec/test/baseline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
findBaselineProvider,
maybeSeedBaseline,
uploadBaselines,
waitForSeedBuild,
sanitizePath,
sanitizeDirentName
} from '../src/baseline.js';
Expand Down Expand Up @@ -146,6 +147,47 @@ 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('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();
Expand Down
Loading