Skip to content
Draft
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
19 changes: 19 additions & 0 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down
55 changes: 55 additions & 0 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,41 @@
.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'];

Check failure on line 393 in packages/core/src/snapshot.js

View workflow job for this annotation

GitHub Actions / Lint

["base_build_commit_sha"] is better written in dot notation

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) {
Expand Down
75 changes: 72 additions & 3 deletions packages/core/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
92 changes: 92 additions & 0 deletions packages/core/test/snapshot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Test</p>'
});
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: '<p>Test</p>'
});
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: '<p>Test</p>'
});
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);
Expand Down
Loading