diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 119958988..71d0fa934 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -241,6 +241,25 @@ export class PercyClient { }); } + // Requests a TurboSnap affected-components filter for the active build. + // Forwards changed files, gzipped webpack module edges, and component file + // paths to the Percy API. The response is JSONAPI and includes either the + // affected file paths or a bail signal telling the SDK to snapshot all. + async turbosnap(buildId, { changedFiles, webpackStatsGz, componentFilePaths } = {}) { + validateId('build', buildId); + this.log.debug(`Requesting TurboSnap filter for build ${buildId}...`); + return this.post(`builds/${buildId}/turbosnap`, { + data: { + type: 'turbosnap-requests', + attributes: { + 'changed-files': changedFiles, + 'webpack-stats-gz': webpackStatsGz, + 'component-file-paths': componentFilePaths + } + } + }, { identifier: 'build.turbosnap' }); + } + // Finalizes the active build. When `all` is true, `all-shards=true` is // added as a query param so the API finalizes all other build shards. async finalizeBuild(buildId, { all = false } = {}) { diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 67f1df5b7..cacabaf3c 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -897,6 +897,61 @@ describe('PercyClient', () => { }); }); + describe('#turbosnap()', () => { + it('throws when missing a build id', async () => { + await expectAsync(client.turbosnap()) + .toBeRejectedWithError('Missing build ID'); + await expectAsync(client.turbosnap({ changedFiles: [] })) + .toBeRejectedWithError('Invalid build ID'); + }); + + it('posts turbosnap data and returns the API response', async () => { + api.reply('/builds/123/turbosnap', () => [200, { + data: { + id: '123', + type: 'turbosnap-results', + attributes: { 'affected-file-paths': ['src/Button.jsx'] } + } + }]); + + await expectAsync(client.turbosnap(123, { + changedFiles: ['src/Button.jsx'], + webpackStatsGz: 'H4sIAAAA', + componentFilePaths: ['src/Button.jsx', 'src/Input.jsx'] + })).toBeResolvedTo({ + data: { + id: '123', + type: 'turbosnap-results', + attributes: { 'affected-file-paths': ['src/Button.jsx'] } + } + }); + + expect(api.requests['/builds/123/turbosnap'][0].body).toEqual({ + data: { + type: 'turbosnap-requests', + attributes: { + 'changed-files': ['src/Button.jsx'], + 'webpack-stats-gz': 'H4sIAAAA', + 'component-file-paths': ['src/Button.jsx', 'src/Input.jsx'] + } + } + }); + }); + + it('omits missing body fields', async () => { + await expectAsync(client.turbosnap(123)).toBeResolved(); + + // undefined attributes are dropped by JSON.stringify so the posted body + // contains just the type marker. + expect(api.requests['/builds/123/turbosnap'][0].body).toEqual({ + data: { + type: 'turbosnap-requests', + attributes: {} + } + }); + }); + }); + describe('#uploadResource()', () => { it('throws when missing a build id', async () => { await expectAsync(client.uploadResource()) diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 9455b214b..912c130b8 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -215,6 +215,29 @@ export function createPercyServer(percy, port) { res.json(200, { success: true }); }) + // forwards a TurboSnap request to the Percy API and returns the response. + // On any internal error we reply 200 with a bail envelope so the SDK + // transparently falls back to a full snapshot instead of surfacing an error. + .route('post', '/percy/turbosnap', async (req, res) => { + let buildId = percy.build?.id; + if (!buildId) { + return res.json(400, { error: 'No active build', success: false }); + } + + try { + let result = await percy.client.turbosnap(buildId, req.body); + return res.json(200, result); + } catch (e) { + percy.log.warn(`TurboSnap API error: ${e.message}. Falling back to full snapshot.`); + return res.json(200, { + data: { + id: buildId, + type: 'turbosnap-results', + attributes: { bail: true, 'bail-reason': 'API request failed' } + } + }); + } + }) // stops percy at the end of the current event loop .route('/percy/stop', (req, res) => { setImmediate(() => percy.stop()); diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index b93be4ddb..6b3822042 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -374,12 +374,41 @@ export function createSnapshotsQueue(percy) { .handle('start', async () => { try { build = percy.build = {}; - let { data } = await percy.client.createBuild({ projectType: percy.projectType, cliStartTime: percy.cliStartTime }); + let { data, included } = await percy.client.createBuild({ projectType: percy.projectType, cliStartTime: percy.cliStartTime }); let url = data.attributes['web-url']; let number = data.attributes['build-number']; let usageWarning = data.attributes['usage-warning']; percy.client.buildType = data.attributes?.type; - Object.assign(build, { id: data.id, url, number }); + + // Extract baseline commit SHA from the JSONAPI `included` array so + // TurboSnap-capable SDKs can compute a changed-file set relative to + // the baseline. Missing → null (first build in a project). + // + // Two paths: + // 1. Git workflow — `base-build-strategies` carries baseline_context.base_build_commit_sha. + // 2. Branchline/visual-git workflow — no base-build-strategy; traverse + // data.relationships.base-build -> builds#N.relationships.commit -> commits#M.sha. + // `base-build.commit` is already in SHARED_INCLUDES so the graph is in `included`. + let baseBuildStrategy = included?.find(r => r.type === 'base-build-strategies'); + let baselineCommitSha = baseBuildStrategy?.attributes?.['baseline-context']?.['base_build_commit_sha']; + + if (!baselineCommitSha) { + let baseBuildRef = data.relationships?.['base-build']?.data; + if (baseBuildRef) { + let baseBuild = included?.find(r => r.type === 'builds' && r.id === baseBuildRef.id); + let baseCommitRef = baseBuild?.relationships?.commit?.data; + if (baseCommitRef) { + let baseCommit = included?.find(r => r.type === 'commits' && r.id === baseCommitRef.id); + baselineCommitSha = baseCommit?.attributes?.sha ?? null; + } + } + } + + Object.assign(build, { id: data.id, url, number, baselineCommitSha: baselineCommitSha ?? null }); + + if (baselineCommitSha) { + percy.log.debug(`TurboSnap: baseline commit ${baselineCommitSha.slice(0, 7)}`); + } // Display usage warning if present if (usageWarning) { diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index e0a5731fd..cc11e91bd 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -65,7 +65,8 @@ describe('API Server', () => { build: { id: '123', number: 1, - url: 'https://percy.io/test/test/123' + url: 'https://percy.io/test/test/123', + baselineCommitSha: null }, type: percy.client.tokenType() }); @@ -391,7 +392,7 @@ describe('API Server', () => { expect(captureScreenshotSpy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ clientInfo: 'client', environmentInfo: 'environment', - buildInfo: { id: '123', url: 'https://percy.io/test/test/123', number: 1 }, + buildInfo: { id: '123', url: 'https://percy.io/test/test/123', number: 1, baselineCommitSha: null }, options: { fullPage: true, freezeAnimatedImage: true, @@ -448,7 +449,7 @@ describe('API Server', () => { expect(captureScreenshotSpy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ clientInfo: 'client', environmentInfo: 'environment', - buildInfo: { id: '123', url: 'https://percy.io/test/test/123', number: 1 }, + buildInfo: { id: '123', url: 'https://percy.io/test/test/123', number: 1, baselineCommitSha: null }, options: { sync: true, fullPage: true, @@ -615,6 +616,74 @@ describe('API Server', () => { expect(sdkLogs[1].meta).toEqual(message2.meta); }); + describe('/percy/turbosnap', () => { + it('returns 400 when there is no active build', async () => { + await percy.start(); + // explicitly drop the active build to simulate "no build yet" + percy.build = null; + + let [data, res] = await request('/percy/turbosnap', { + method: 'POST', + body: { changedFiles: ['a.js'] } + }, true); + + expect(res.statusCode).toBe(400); + expect(data).toEqual(jasmine.objectContaining({ + error: 'No active build', + success: false + })); + }); + + it('forwards the request to the client and returns the API response', async () => { + let turboResponse = { + data: { + id: '123', + type: 'turbosnap-results', + attributes: { 'affected-file-paths': ['src/Button.jsx'] } + } + }; + let spy = spyOn(percy.client, 'turbosnap').and.resolveTo(turboResponse); + await percy.start(); + + await expectAsync(request('/percy/turbosnap', { + method: 'POST', + body: { + changedFiles: ['src/Button.jsx'], + webpackStatsGz: 'H4sIAAAA', + componentFilePaths: ['src/Button.jsx', 'src/Input.jsx'] + } + })).toBeResolvedTo(turboResponse); + + expect(spy).toHaveBeenCalledWith('123', jasmine.objectContaining({ + changedFiles: ['src/Button.jsx'], + webpackStatsGz: 'H4sIAAAA', + componentFilePaths: ['src/Button.jsx', 'src/Input.jsx'] + })); + }); + + it('returns 200 with bail envelope when the client throws', async () => { + spyOn(percy.client, 'turbosnap').and.rejectWith(new Error('boom')); + await percy.start(); + + let [data, res] = await request('/percy/turbosnap', { + method: 'POST', + body: { changedFiles: [] } + }, true); + + expect(res.statusCode).toBe(200); + expect(data).toEqual({ + data: { + id: '123', + type: 'turbosnap-results', + attributes: { bail: true, 'bail-reason': 'API request failed' } + } + }); + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/TurboSnap API error: boom\. Falling back to full snapshot\./) + ])); + }); + }); + it('returns a 500 error when an endpoint throws', async () => { spyOn(percy, 'snapshot').and.rejectWith(new Error('test error')); await percy.start(); diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index b8dbebad2..f938d9f89 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -383,6 +383,98 @@ describe('Snapshot', () => { }); }); + describe('baseline commit extraction', () => { + it('extracts baselineCommitSha from the createBuild `included` array', async () => { + await percy.stop(true); + await api.mock(); + + const sha = 'abcdef1234567890abcdef1234567890abcdef12'; + api.reply('/builds', () => [201, { + data: { + id: '123', + attributes: { + 'build-number': 1, + 'web-url': 'https://percy.io/test/test/123' + } + }, + included: [{ + type: 'base-build-strategies', + attributes: { + 'baseline-context': { base_build_commit_sha: sha } + } + }] + }]); + + logger.reset(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + loglevel: 'debug', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + clientInfo: 'client-info', + environmentInfo: 'env-info', + server: false, + projectType: 'web' + }); + + await percy.snapshot({ + url: 'http://localhost:8000', + domSnapshot: '

Test

' + }); + await percy.idle(); + + expect(percy.build.baselineCommitSha).toBe(sha); + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/TurboSnap: baseline commit abcdef1/) + ])); + }); + + it('defaults baselineCommitSha to null when `included` is absent', async () => { + await percy.snapshot({ + url: 'http://localhost:8000', + domSnapshot: '

Test

' + }); + await percy.idle(); + + expect(percy.build.baselineCommitSha).toBeNull(); + }); + + it('defaults baselineCommitSha to null when no base-build-strategies entry present', async () => { + await percy.stop(true); + await api.mock(); + + api.reply('/builds', () => [201, { + data: { + id: '123', + attributes: { + 'build-number': 1, + 'web-url': 'https://percy.io/test/test/123' + } + }, + included: [{ type: 'some-other-thing', attributes: {} }] + }]); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + clientInfo: 'client-info', + environmentInfo: 'env-info', + server: false, + projectType: 'web' + }); + + await percy.snapshot({ + url: 'http://localhost:8000', + domSnapshot: '

Test

' + }); + await percy.idle(); + + expect(percy.build.baselineCommitSha).toBeNull(); + }); + }); + it('uploads snapshots before the next one when delayed', async () => { // stop and recreate a percy instance with the desired option await percy.stop(true);