From b09794ad433f5f6a32c5d8c403c359a0a706ee1d Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 11 May 2026 21:13:57 +0530 Subject: [PATCH 1/8] feat(core): compute md5 + contentLength alongside sha at resource creation (PPLT-5303) Prep for direct bucket upload: percy-api needs to mint Content-MD5 / Content-Length-bound signed GCS URLs on snapshot creation, so the CLI must declare both alongside the existing sha-256. Computed once at createResource() time so every resource factory (createResource, createRootResource, createPercyCSSResource, createLogResource) gets the fields for free. PERCY_GZIP path in discovery.js keeps md5 + contentLength in lockstep with sha when it mutates the content buffer, so direct upload composes with gzip. Wire shape is not changed yet; these fields are read by Step 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/src/utils.js | 9 ++++ packages/client/test/unit/utils.test.js | 24 ++++++++++- packages/core/src/discovery.js | 5 ++- packages/core/src/utils.js | 16 +++++-- packages/core/test/utils.test.js | 56 ++++++++++++++++++++++++- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 323d4ca9b..212641856 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -25,6 +25,15 @@ export function sha256hash(content) { .digest('hex'); } +// Returns a base64-encoded MD5 of a string or buffer. Used as Content-MD5 for +// GCS direct-bucket-upload integrity (RFC 1864), declared alongside the SHA-256. +export function md5base64(content) { + return crypto + .createHash('md5') + .update(content, 'utf-8') + .digest('base64'); +} + // Returns a base64 encoding of a string or buffer. export function base64encode(content) { return Buffer diff --git a/packages/client/test/unit/utils.test.js b/packages/client/test/unit/utils.test.js index ca34c2605..422a0813a 100644 --- a/packages/client/test/unit/utils.test.js +++ b/packages/client/test/unit/utils.test.js @@ -1,6 +1,28 @@ -import { normalizeBrowsers } from '@percy/client/utils'; +import { md5base64, normalizeBrowsers, sha256hash } from '@percy/client/utils'; +import crypto from 'crypto'; describe('utils', () => { + describe('md5base64', () => { + it('returns the base64-encoded MD5 of a string', () => { + // RFC 1864 test vector: empty string MD5 base64 + expect(md5base64('')).toEqual('1B2M2Y8AsgTpgAmY7PhCfg=='); + }); + + it('returns the base64-encoded MD5 of a buffer', () => { + const buf = Buffer.from('hello world', 'utf-8'); + const expected = crypto.createHash('md5').update(buf).digest('base64'); + expect(md5base64(buf)).toEqual(expected); + }); + + it('treats strings as UTF-8 (same encoding as sha256hash)', () => { + const utf8 = 'café — résumé'; + const expectedMd5 = crypto.createHash('md5').update(utf8, 'utf-8').digest('base64'); + const expectedSha = crypto.createHash('sha256').update(utf8, 'utf-8').digest('hex'); + expect(md5base64(utf8)).toEqual(expectedMd5); + expect(sha256hash(utf8)).toEqual(expectedSha); + }); + }); + describe('normalizeBrowsers', () => { describe('when browser values are in kebabcase', () => { it('returns snakecase values', () => { diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index f5e7c015d..f4edf8cfc 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -18,7 +18,8 @@ import { } from './utils.js'; import { ByteLRU, entrySize, DiskSpillStore, createSpillDir } from './cache/byte-lru.js'; import { - sha256hash + sha256hash, + md5base64 } from '@percy/client/utils'; import Pako from 'pako'; @@ -228,6 +229,8 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { if (!alreadyZipped) { resources[index].content = Pako.gzip(resources[index].content); resources[index].sha = sha256hash(resources[index].content); + resources[index].md5 = md5base64(resources[index].content); + resources[index].contentLength = Buffer.byteLength(resources[index].content, 'utf-8'); } } } diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index dee1dea6f..a334f243f 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -1,5 +1,5 @@ import EventEmitter from 'events'; -import { sha256hash, request } from '@percy/client/utils'; +import { sha256hash, md5base64, request } from '@percy/client/utils'; import { camelcase, merge } from '@percy/config/utils'; import YAML from 'yaml'; import path from 'path'; @@ -301,9 +301,19 @@ function processSendEventData(input, cliVersion) { } // Creates a local resource object containing the resource URL, mimetype, content, sha, and any -// other additional resources attributes. +// other additional resources attributes. md5 + contentLength are declared on snapshot creation +// so percy-api can mint Content-MD5/Content-Length-bound signed URLs for direct bucket upload; +// they must stay in lockstep with the bytes in `content` (see discovery.js PERCY_GZIP path). export function createResource(url, content, mimetype, attrs) { - return { ...attrs, sha: sha256hash(content), mimetype, content, url }; + return { + ...attrs, + sha: sha256hash(content), + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8'), + mimetype, + content, + url + }; } // Creates a root resource object with an additional `root: true` property. The URL is normalized diff --git a/packages/core/test/utils.test.js b/packages/core/test/utils.test.js index 09374b6e2..0ca19771e 100644 --- a/packages/core/test/utils.test.js +++ b/packages/core/test/utils.test.js @@ -1,4 +1,5 @@ -import { decodeAndEncodeURLWithLogging, waitForSelectorInsideBrowser, compareObjectTypes, isGzipped, checkSDKVersion, percyAutomateRequestHandler, detectFontMimeType, handleIncorrectFontMimeType, computeResponsiveWidths, appendUrlSearchParam, processCorsIframesInDomSnapshot, processCorsIframes } from '../src/utils.js'; +import { createLogResource, createPercyCSSResource, createResource, createRootResource, decodeAndEncodeURLWithLogging, waitForSelectorInsideBrowser, compareObjectTypes, isGzipped, checkSDKVersion, percyAutomateRequestHandler, detectFontMimeType, handleIncorrectFontMimeType, computeResponsiveWidths, appendUrlSearchParam, processCorsIframesInDomSnapshot, processCorsIframes } from '../src/utils.js'; +import { md5base64, sha256hash } from '@percy/client/utils'; import { logger, setupTest, mockRequests } from './helpers/index.js'; import percyLogger from '@percy/logger'; import Percy from '@percy/core'; @@ -14,6 +15,59 @@ describe('utils', () => { await logger.mock({ level: 'debug' }); }); + describe('createResource', () => { + it('returns sha, md5, contentLength, mimetype, content, and url', () => { + const content = 'p { color: purple; }'; + const r = createResource('https://example.com/a.css', content, 'text/css'); + expect(r).toEqual({ + url: 'https://example.com/a.css', + mimetype: 'text/css', + content, + sha: sha256hash(content), + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8') + }); + }); + + it('preserves extra attrs and computes byte length over UTF-8', () => { + const content = 'café — résumé'; + const r = createResource('https://example.com/x', content, 'text/plain', { root: true }); + expect(r.root).toBeTrue(); + expect(r.contentLength).toEqual(Buffer.byteLength(content, 'utf-8')); + expect(r.contentLength).not.toEqual(content.length); // sanity: multi-byte chars + }); + + it('handles Buffer content', () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0xff]); + const r = createResource('https://example.com/blob', buf, 'application/octet-stream'); + expect(r.contentLength).toEqual(buf.length); + expect(r.md5).toEqual(md5base64(buf)); + expect(r.sha).toEqual(sha256hash(buf)); + }); + + it('propagates new fields through createRootResource', () => { + const html = 'hi'; + const r = createRootResource('https://example.com/', html); + expect(r.root).toBeTrue(); + expect(r.md5).toEqual(md5base64(html)); + expect(r.contentLength).toEqual(Buffer.byteLength(html, 'utf-8')); + }); + + it('propagates new fields through createPercyCSSResource', () => { + const css = 'body { color: red; }'; + const r = createPercyCSSResource('https://example.com/page', css); + expect(r.md5).toEqual(md5base64(css)); + expect(r.contentLength).toEqual(Buffer.byteLength(css, 'utf-8')); + }); + + it('propagates new fields through createLogResource', () => { + const r = createLogResource([{ level: 'info', message: 'hi' }]); + expect(r.log).toBeTrue(); + expect(r.md5).toEqual(md5base64(r.content)); + expect(r.contentLength).toEqual(Buffer.byteLength(r.content, 'utf-8')); + }); + }); + describe('percyAutomateRequestHandler', () => { it('maps client/environment info, camelCases options, merges config, concatenates percyCSS and attaches buildInfo', () => { const req = { From ebaeeb7a00124c6b5ab7d4c62a97897189dc9f63 Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 11 May 2026 21:20:18 +0530 Subject: [PATCH 2/8] feat(client): rawBody option for request() to send Buffer bodies verbatim (PPLT-5303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct bucket upload needs to PUT raw bytes to a GCS signed URL with caller-supplied Content-MD5 / Content-Length / Content-Type. The shared request() helper currently JSON-stringifies any non-string body and forces Content-Type: application/json — that wraps the bytes and breaks GCS's Content-MD5 check. Add an explicit rawBody flag. When set, body and headers pass through unchanged. Retries, proxy handling, error parsing are unaffected. Default behavior is unchanged for every existing caller. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/src/utils.js | 7 ++-- packages/client/test/unit/request.test.js | 45 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 212641856..344dfef54 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -141,7 +141,7 @@ export async function request(url, options = {}, callback) { // gather request options let { body, headers, retries, retryNotFound, - interval, noProxy, buffer, meta = {}, ...requestOptions + interval, noProxy, buffer, rawBody, meta = {}, ...requestOptions } = options; let { protocol, hostname, port, pathname, search, hash } = new URL(url); @@ -151,8 +151,9 @@ export async function request(url, options = {}, callback) { let { default: http } = protocol === 'https:' ? await import('https') : await import('http'); let { proxyAgentFor } = await import('./proxy.js'); - // automatically stringify body content - if (body !== undefined && typeof body !== 'string') { + // rawBody: send body and headers verbatim (e.g. binary PUT to a GCS signed URL). + // Otherwise stringify non-string bodies as JSON. + if (!rawBody && body !== undefined && typeof body !== 'string') { headers = { 'Content-Type': 'application/json', ...headers }; body = JSON.stringify(body); } diff --git a/packages/client/test/unit/request.test.js b/packages/client/test/unit/request.test.js index 05fe441e7..6cafe0d64 100644 --- a/packages/client/test/unit/request.test.js +++ b/packages/client/test/unit/request.test.js @@ -210,6 +210,51 @@ describe('Unit / Request', () => { .toBeRejectedWithError('403 \nSTOP'); }); + describe('rawBody', () => { + it('JSON-stringifies non-string bodies and forces application/json by default', async () => { + await server.request('/', { + method: 'POST', + body: { hello: 'world' } + }); + + const req = server.received[0]; + expect(req.headers['content-type']).toBe('application/json'); + expect(req.body).toBe('{"hello":"world"}'); + }); + + it('sends Buffer body verbatim with caller-supplied headers when rawBody is true', async () => { + const payload = Buffer.from('raw-bytes-here', 'utf-8'); + + await server.request('/', { + method: 'PUT', + rawBody: true, + body: payload, + headers: { + 'Content-Type': 'image/png', + 'Content-MD5': 'fakebase64md5==', + 'Content-Length': payload.length + } + }); + + const req = server.received[0]; + expect(req.headers['content-type']).toBe('image/png'); + expect(req.headers['content-md5']).toBe('fakebase64md5=='); + expect(req.headers['content-length']).toBe(String(payload.length)); + expect(Buffer.from(req.body).toString('utf-8')).toBe('raw-bytes-here'); + }); + + it('does not inject Content-Type when rawBody is true and caller omits one', async () => { + await server.request('/', { + method: 'PUT', + rawBody: true, + body: Buffer.from('x') + }); + + const req = server.received[0]; + expect(req.headers['content-type']).toBeUndefined(); + }); + }); + describe('retries', () => { it('automatically retries server 500 errors', async () => { let responses = [[502], [503], [520], [200]]; From c36c538342f0b42705bbd10ececc977ef84281e8 Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 11 May 2026 21:30:58 +0530 Subject: [PATCH 3/8] feat(client): advertise direct-upload-v1 capability + declare content-md5/length on snapshot creation (PPLT-5303) Adds the wire contract for direct bucket upload on the snapshot side: - X-Percy-Capabilities: direct-upload-v1 header on POST /builds/:id/snapshots. Old percy-api ignores it. New percy-api uses it as one of the eligibility checks before minting Content-MD5-bound GCS signed URLs. - content-md5 and content-length attrs alongside the existing resource-url / is-root / for-widths / mimetype. Prefers caller-supplied r.md5 / r.contentLength (set by createResource in Step 1); falls back to computing on the fly when resources arrive without them (e.g. filepath-loaded). Null when content is absent (sha-only resources), which the server treats as ineligible and routes through legacy. - PERCY_DISABLE_DIRECT_UPLOAD env var suppresses the capability header for customers who hit latency issues and want to self-serve back to the legacy POST /resources path without waiting on a server-side flag flip. createBuild parity is intentionally not touched until the percy-api side confirms it accepts the new attrs (D3 in the plan). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/src/client.js | 16 ++++++- packages/client/test/client.test.js | 68 ++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 452c60e93..d8c62e8bf 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -9,6 +9,7 @@ import { request, formatBytes, sha256hash, + md5base64, base64encode, getPackageJSON, waitForTimeout, @@ -310,6 +311,15 @@ export class PercyClient { }); } + // Returns capability headers advertised on snapshot creation. `direct-upload-v1` + // tells percy-api to mint Content-MD5/Content-Length-bound GCS signed URLs in + // the missing-resources response. PERCY_DISABLE_DIRECT_UPLOAD is the + // customer-side escape hatch that forces fallback to POST /resources. + directUploadCapabilityHeaders() { + if (process.env.PERCY_DISABLE_DIRECT_UPLOAD) return {}; + return { 'X-Percy-Capabilities': 'direct-upload-v1' }; + } + // Creates a build with optional build resources. Only one build can be // created at a time per instance so snapshots and build finalization can be // done more seamlessly without manually tracking build ids @@ -630,13 +640,15 @@ export class PercyClient { 'resource-url': r.url || null, 'is-root': r.root || null, 'for-widths': r.widths || null, - mimetype: r.mimetype || null + mimetype: r.mimetype || null, + 'content-md5': r.md5 ?? (r.content != null ? md5base64(r.content) : null), + 'content-length': r.contentLength ?? (r.content != null ? Buffer.byteLength(r.content, 'utf-8') : null) } })) } } } - }, { identifier: 'snapshot.post', ...meta }); + }, { identifier: 'snapshot.post', ...meta }, this.directUploadCapabilityHeaders()); } // Finalizes a snapshot. diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 6227423ae..3173a01c2 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1,7 +1,7 @@ import fs from 'fs'; import logger from '@percy/logger/test/helpers'; import { mockgit } from '@percy/env/test/helpers'; -import { sha256hash, base64encode } from '@percy/client/utils'; +import { md5base64, sha256hash, base64encode } from '@percy/client/utils'; import PercyClient from '@percy/client'; import api, { mockRequests } from './helpers.js'; import * as CoreConfig from '@percy/core/config'; @@ -1222,7 +1222,9 @@ describe('PercyClient', () => { 'resource-url': '/foo', mimetype: 'text/html', 'for-widths': [1000], - 'is-root': true + 'is-root': true, + 'content-md5': md5base64('foo'), + 'content-length': Buffer.byteLength('foo', 'utf-8') } }, { type: 'resources', @@ -1231,7 +1233,9 @@ describe('PercyClient', () => { 'resource-url': '/bar', mimetype: 'image/png', 'for-widths': null, - 'is-root': null + 'is-root': null, + 'content-md5': md5base64('bar'), + 'content-length': Buffer.byteLength('bar', 'utf-8') } }] } @@ -1314,7 +1318,9 @@ describe('PercyClient', () => { 'resource-url': '/foo', mimetype: 'text/html', 'for-widths': [1000], - 'is-root': true + 'is-root': true, + 'content-md5': md5base64('foo'), + 'content-length': Buffer.byteLength('foo', 'utf-8') } }, { type: 'resources', @@ -1323,7 +1329,9 @@ describe('PercyClient', () => { 'resource-url': '/bar', mimetype: 'image/png', 'for-widths': null, - 'is-root': null + 'is-root': null, + 'content-md5': md5base64('bar'), + 'content-length': Buffer.byteLength('bar', 'utf-8') } }] } @@ -1365,7 +1373,9 @@ describe('PercyClient', () => { 'resource-url': null, 'for-widths': null, 'is-root': null, - mimetype: null + mimetype: null, + 'content-md5': null, + 'content-length': null } }] } @@ -1373,6 +1383,48 @@ describe('PercyClient', () => { } }); }); + + it('advertises direct-upload-v1 capability header by default', async () => { + await expectAsync( + client.createSnapshot(123, { name: 'cap-test', resources: [{ sha: 'sha' }] }) + ).toBeResolved(); + + expect(api.requests['/builds/123/snapshots'][0].headers).toEqual( + jasmine.objectContaining({ 'X-Percy-Capabilities': 'direct-upload-v1' }) + ); + }); + + it('suppresses capability header when PERCY_DISABLE_DIRECT_UPLOAD is set', async () => { + process.env.PERCY_DISABLE_DIRECT_UPLOAD = '1'; + try { + await expectAsync( + client.createSnapshot(123, { name: 'cap-test', resources: [{ sha: 'sha' }] }) + ).toBeResolved(); + + expect(api.requests['/builds/123/snapshots'][0].headers['X-Percy-Capabilities']).toBeUndefined(); + } finally { + delete process.env.PERCY_DISABLE_DIRECT_UPLOAD; + } + }); + + it('prefers caller-supplied md5/contentLength over recomputing from content', async () => { + await expectAsync( + client.createSnapshot(123, { + name: 'precomputed', + resources: [{ + url: '/x', + sha: 'precomputed-sha', + content: 'foo', + md5: 'caller-md5-base64', + contentLength: 999 + }] + }) + ).toBeResolved(); + + const attrs = api.requests['/builds/123/snapshots'][0].body.data.relationships.resources.data[0].attributes; + expect(attrs['content-md5']).toBe('caller-md5-base64'); + expect(attrs['content-length']).toBe(999); + }); }); describe('#finalizeSnapshot()', () => { @@ -1438,7 +1490,9 @@ describe('PercyClient', () => { mimetype: 'text/html', 'resource-url': null, 'for-widths': [1000], - 'is-root': true + 'is-root': true, + 'content-md5': md5base64(testDOM), + 'content-length': Buffer.byteLength(testDOM, 'utf-8') } }] } From 3fc460863f57abcd434e7f730c60172225370acc Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 11 May 2026 21:38:06 +0530 Subject: [PATCH 4/8] feat(client): uploadResourceDirect + uploadResourcesDirect primitives for GCS PUT (PPLT-5303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUTs a resource's raw bytes to its percy-api-issued GCS signed URL using request() with rawBody: true. Sends Content-MD5, Content-Length, and Content-Type headers matching what percy-api baked into the signature. Failure handling distinguishes two classes: - Transport (network errors, 5xx after retries, 403 expired, 429 rate-limited): the resource is returned to the caller so it can be routed through the legacy POST /resources path. Build still succeeds. - Correctness (400 BadDigest, 412 Precondition Failed): thrown. These indicate a real bug — either the CLI computed md5/length wrong, or a single-use signed URL is being replayed. Silently falling back here would mask the regression; the customer's build fails loud instead. uploadResourcesDirect is a pool-based parallel orchestrator sharing PERCY_RESOURCE_UPLOAD_CONCURRENCY with the legacy uploader. Returns the list of transport-failed resources for the caller to retry via legacy. Not yet wired into sendSnapshot — Step 5 does that. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/src/client.js | 60 ++++++++++++++ packages/client/test/client.test.js | 123 ++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index d8c62e8bf..5caa50601 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -572,6 +572,66 @@ export class PercyClient { }, this, uploadConcurrency); } + // PUTs a single resource directly to its percy-api-issued GCS signed URL. + // Returns the resource when the upload hits a transport-class failure + // (caller falls back to legacy POST /resources). Throws on correctness-class + // failures (400 BadDigest, 412 if-generation-match) — those signal a real + // bug in the declared md5/length or a leaked URL replay, and must not be + // silently masked by the fallback path. + async uploadResourceDirect(resource, meta = {}) { + let { signedUploadUrl, content, md5, contentLength, url, mimetype } = resource; + this.log.debug(`Direct-uploading ${formatBytes(contentLength)} resource: ${url}`, meta); + this.mayBeLogUploadSize(contentLength, meta); + + let headers = { + 'Content-MD5': md5, + 'Content-Length': contentLength + }; + if (mimetype) headers['Content-Type'] = mimetype; + + try { + await request(signedUploadUrl, { + method: 'PUT', + rawBody: true, + body: content, + headers, + meta + }); + return null; + } catch (err) { + let status = err.response?.statusCode; + if (status === 400 || status === 412) throw err; + this.log.debug(`Direct upload failed for ${url}, falling back to legacy: ${err.message}`, meta); + return resource; + } + } + + // Uploads resources directly to GCS signed URLs concurrently. Returns the + // list of resources that hit transport-class failures so the caller can + // route them through the legacy upload path. Concurrency is shared with + // the legacy uploader via PERCY_RESOURCE_UPLOAD_CONCURRENCY. + async uploadResourcesDirect(resources, meta = {}) { + this.log.debug(`Direct-uploading ${resources.length} resource(s)...`, meta); + + const uploadConcurrency = parseInt(process.env.PERCY_RESOURCE_UPLOAD_CONCURRENCY) || 2; + let fallback = []; + + await pool(function*() { + for (let resource of resources) { + let resourceMeta = { + url: resource.url, + sha: resource.sha, + ...meta + }; + yield this.uploadResourceDirect(resource, resourceMeta).then(failed => { + if (failed) fallback.push(failed); + }); + } + }, this, uploadConcurrency); + + return fallback; + } + // Creates a snapshot for the active build using the provided attributes. async createSnapshot(buildId, { name, diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 3173a01c2..52eb458bf 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1142,6 +1142,129 @@ describe('PercyClient', () => { }); }); + describe('#uploadResourceDirect() / #uploadResourcesDirect()', () => { + const GCS_BASE = 'https://storage.googleapis.com'; + let gcsReply; + + function makeResource(overrides = {}) { + const content = overrides.content ?? 'foo'; + return { + url: '/foo.css', + sha: sha256hash(content), + mimetype: 'text/css', + content, + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8'), + signedUploadUrl: `${GCS_BASE}/percy-resources/ns_gid_${sha256hash(content)}?sig=fake`, + ...overrides + }; + } + + beforeEach(async () => { + gcsReply = await mockRequests(GCS_BASE); + }); + + it('PUTs raw bytes with Content-MD5, Content-Length, Content-Type headers and resolves to null on 200', async () => { + gcsReply.and.callFake(() => [200]); + + const resource = makeResource({ content: 'p { color: purple; }' }); + await expectAsync(client.uploadResourceDirect(resource)).toBeResolvedTo(null); + + const req = gcsReply.calls.mostRecent().args[0]; + expect(req.method).toBe('PUT'); + expect(req.headers['Content-MD5']).toBe(resource.md5); + expect(req.headers['Content-Length']).toBe(resource.contentLength); + expect(req.headers['Content-Type']).toBe('text/css'); + }); + + it('returns the resource for fallback on 403 (signed URL expired)', async () => { + gcsReply.and.callFake(() => [403, 'expired']); + + const resource = makeResource(); + await expectAsync(client.uploadResourceDirect(resource)).toBeResolvedTo(resource); + }); + + it('returns the resource for fallback on 429 (rate limited)', async () => { + gcsReply.and.callFake(() => [429]); + + const resource = makeResource(); + await expectAsync(client.uploadResourceDirect(resource)).toBeResolvedTo(resource); + }); + + it('returns the resource for fallback on 5xx after retries', async () => { + gcsReply.and.callFake(() => [503]); + + const resource = makeResource(); + await expectAsync(client.uploadResourceDirect(resource)).toBeResolvedTo(resource); + }); + + it('throws on 400 BadDigest (correctness failure must surface, not be masked)', async () => { + gcsReply.and.callFake(() => [400, 'BadDigest']); + + const resource = makeResource(); + await expectAsync(client.uploadResourceDirect(resource)).toBeRejected(); + }); + + it('throws on 412 Precondition Failed (object already exists — replay or bug)', async () => { + gcsReply.and.callFake(() => [412, 'PreconditionFailed']); + + const resource = makeResource(); + await expectAsync(client.uploadResourceDirect(resource)).toBeRejected(); + }); + + it('omits Content-Type when resource has no mimetype', async () => { + gcsReply.and.callFake(() => [200]); + + const resource = makeResource(); + delete resource.mimetype; + await client.uploadResourceDirect(resource); + + const req = gcsReply.calls.mostRecent().args[0]; + expect(req.headers['Content-Type']).toBeUndefined(); + }); + + describe('#uploadResourcesDirect()', () => { + it('resolves to empty fallback list when every resource succeeds', async () => { + gcsReply.and.callFake(() => [200]); + + await expectAsync(client.uploadResourcesDirect([ + makeResource({ content: 'a' }), + makeResource({ content: 'b' }) + ])).toBeResolvedTo([]); + }); + + it('collects transport-failed resources for fallback while letting successes pass', async () => { + const resources = [ + makeResource({ content: 'a' }), + makeResource({ content: 'b' }), + makeResource({ content: 'c' }) + ]; + + // succeed for 'a' and 'c', 5xx for 'b' + gcsReply.and.callFake(({ path }) => { + if (path.includes(resources[1].sha)) return [503]; + return [200]; + }); + + const fallback = await client.uploadResourcesDirect(resources); + expect(fallback.length).toBe(1); + expect(fallback[0].sha).toBe(resources[1].sha); + }); + + it('propagates correctness failures from any resource', async () => { + gcsReply.and.callFake(() => [400, 'BadDigest']); + + await expectAsync(client.uploadResourcesDirect([ + makeResource() + ])).toBeRejected(); + }); + + it('does nothing for an empty resource list', async () => { + await expectAsync(client.uploadResourcesDirect([])).toBeResolvedTo([]); + }); + }); + }); + describe('#createSnapshot()', () => { it('throws when missing a build id', async () => { await expectAsync(client.createSnapshot()) From 821c402be69bcbb48c10dd42cbaf23e2c48f5ab2 Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 11 May 2026 21:47:29 +0530 Subject: [PATCH 5/8] feat(client): sendSnapshot partitions missing resources between direct upload and legacy fallback (PPLT-5303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When percy-api advertises direct upload for a build, missing-resources entries may carry signed-upload-url + signed-upload-expires attrs. The new flow: - per-resource partition: signed URL present → direct PUT to GCS via uploadResourcesDirect (Step 4); absent → legacy POST /resources - transport failures returned from the direct path merge back into the legacy bucket, so a partial-failure scenario degrades gracefully and the build still finalizes - correctness failures (400 BadDigest / 412) thrown — these are real bugs, not transport noise to mask - PERCY_DISABLE_DIRECT_UPLOAD forces every resource through legacy, giving customers a self-serve escape hatch without needing the server-side LD flag flipped Also: replaced `!!process.env.X` truthy check with a strict envBool() helper that only accepts "1" or "true" (case-insensitive). Without this, PERCY_DISABLE_DIRECT_UPLOAD=0 would silently disable direct upload — surprising and wrong. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/src/client.js | 35 +++++- packages/client/test/client.test.js | 172 ++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 5caa50601..3b11e7701 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -22,6 +22,15 @@ import { // Default client API URL can be set with an env var for API development const { PERCY_CLIENT_API_URL = 'https://percy.io/api/v1' } = process.env; let pkg = getPackageJSON(import.meta.url); + +// Strict boolean env var parser. Only "1" or "true" (case-insensitive) count as +// enabled. Avoids the JS-truthy trap where "0" or "false" would silently enable +// a flag because both are non-empty strings. +function envBool(value) { + if (value == null) return false; + let v = String(value).toLowerCase(); + return v === '1' || v === 'true'; +} // minimum polling interval milliseconds const MIN_POLLING_INTERVAL = 1_000; const INVALID_TOKEN_ERROR_MESSAGE = 'Unable to retrieve snapshot details with write access token. Kindly use a full access token for retrieving snapshot details with Synchronous CLI.'; @@ -316,7 +325,7 @@ export class PercyClient { // the missing-resources response. PERCY_DISABLE_DIRECT_UPLOAD is the // customer-side escape hatch that forces fallback to POST /resources. directUploadCapabilityHeaders() { - if (process.env.PERCY_DISABLE_DIRECT_UPLOAD) return {}; + if (envBool(process.env.PERCY_DISABLE_DIRECT_UPLOAD)) return {}; return { 'X-Percy-Capabilities': 'direct-upload-v1' }; } @@ -729,7 +738,29 @@ export class PercyClient { this.log.debug(`${missing?.length || 0} Missing resources: ${options.name}...`, meta); if (missing?.length) { let resources = options.resources.reduce((acc, r) => Object.assign(acc, { [r.sha]: r }), {}); - await this.uploadResources(buildId, missing.map(({ id }) => resources[id]), meta); + let disabled = envBool(process.env.PERCY_DISABLE_DIRECT_UPLOAD); + let direct = []; + let legacy = []; + + for (let entry of missing) { + let resource = resources[entry.id]; + if (!resource) continue; + let signedUploadUrl = entry.attributes?.['signed-upload-url']; + if (!disabled && signedUploadUrl) { + direct.push({ ...resource, signedUploadUrl }); + } else { + legacy.push(resource); + } + } + + if (direct.length) { + let failed = await this.uploadResourcesDirect(direct, meta); + legacy.push(...failed); + } + + if (legacy.length) { + await this.uploadResources(buildId, legacy, meta); + } } this.log.debug(`Resources uploaded: ${options.name}...`, meta); diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 52eb458bf..2dc32d4d0 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1530,6 +1530,35 @@ describe('PercyClient', () => { } }); + it('PERCY_DISABLE_DIRECT_UPLOAD uses strict parsing — "0" / "false" / "anything-else" do NOT disable', async () => { + // regression guard: an earlier !!process.env.X would treat any non-empty + // string as truthy, so PERCY_DISABLE_DIRECT_UPLOAD=0 would silently + // disable direct upload despite reading like "off" + for (const v of ['0', 'false', 'no', 'off', 'garbage']) { + process.env.PERCY_DISABLE_DIRECT_UPLOAD = v; + try { + await client.createSnapshot(123, { name: `cap-${v}`, resources: [{ sha: 'sha' }] }); + const last = api.requests['/builds/123/snapshots'].slice(-1)[0]; + expect(last.headers['X-Percy-Capabilities']).toBe('direct-upload-v1'); + } finally { + delete process.env.PERCY_DISABLE_DIRECT_UPLOAD; + } + } + }); + + it('PERCY_DISABLE_DIRECT_UPLOAD strict-true values (1/true, case-insensitive) all disable', async () => { + for (const v of ['1', 'true', 'True', 'TRUE']) { + process.env.PERCY_DISABLE_DIRECT_UPLOAD = v; + try { + await client.createSnapshot(123, { name: `cap-${v}`, resources: [{ sha: 'sha' }] }); + const last = api.requests['/builds/123/snapshots'].slice(-1)[0]; + expect(last.headers['X-Percy-Capabilities']).toBeUndefined(); + } finally { + delete process.env.PERCY_DISABLE_DIRECT_UPLOAD; + } + } + }); + it('prefers caller-supplied md5/contentLength over recomputing from content', async () => { await expectAsync( client.createSnapshot(123, { @@ -1652,6 +1681,149 @@ describe('PercyClient', () => { await expectAsync(client.sendSnapshot(123, { name: 'test snapshot name' })).toBeResolved(); expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); }); + + describe('direct bucket upload', () => { + const GCS_BASE = 'https://storage.googleapis.com'; + let gcsReply; + + function withSignedUrls(reqBody, includeFor = () => true) { + return { + data: { + id: '4567', + attributes: reqBody.attributes, + relationships: { + 'missing-resources': { + data: reqBody.data.relationships.resources.data.map(({ id }) => ( + includeFor(id) + ? { id, attributes: { 'signed-upload-url': `${GCS_BASE}/percy-resources/ns_gid_${id}?sig=fake` } } + : { id } + )) + } + } + } + }; + } + + beforeEach(async () => { + gcsReply = await mockRequests(GCS_BASE); + }); + + it('PUTs directly to GCS when the server returns signed-upload-url', async () => { + gcsReply.and.callFake(() => [200]); + api.reply('/builds/123/snapshots', ({ body }) => [201, withSignedUrls(body)]); + + let content = 'foo-content'; + await expectAsync( + client.sendSnapshot(123, { + name: 'direct-test', + resources: [{ + url: '/foo.css', + sha: sha256hash(content), + mimetype: 'text/css', + content, + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8'), + root: true + }] + }) + ).toBeResolved(); + + expect(gcsReply).toHaveBeenCalledTimes(1); + expect(gcsReply.calls.mostRecent().args[0].method).toBe('PUT'); + expect(gcsReply.calls.mostRecent().args[0].headers['Content-MD5']).toBe(md5base64(content)); + // legacy upload path NOT used when direct succeeds + expect(api.requests['/builds/123/resources']).toBeUndefined(); + expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); + }); + + it('partitions per-resource: signed URL → direct, no URL → legacy', async () => { + gcsReply.and.callFake(() => [200]); + let directContent = 'direct-bytes'; + let legacyContent = 'legacy-bytes'; + let directSha = sha256hash(directContent); + let legacySha = sha256hash(legacyContent); + + api.reply('/builds/123/snapshots', ({ body }) => [201, withSignedUrls(body, id => id === directSha)]); + + await expectAsync( + client.sendSnapshot(123, { + name: 'mixed', + resources: [ + { url: '/d', sha: directSha, content: directContent, mimetype: 'text/plain', md5: md5base64(directContent), contentLength: Buffer.byteLength(directContent, 'utf-8') }, + { url: '/l', sha: legacySha, content: legacyContent, mimetype: 'text/plain', md5: md5base64(legacyContent), contentLength: Buffer.byteLength(legacyContent, 'utf-8') } + ] + }) + ).toBeResolved(); + + expect(gcsReply).toHaveBeenCalledTimes(1); + expect(api.requests['/builds/123/resources']).toBeDefined(); + expect(api.requests['/builds/123/resources'].length).toBe(1); + expect(api.requests['/builds/123/resources'][0].body.data.id).toBe(legacySha); + }); + + it('PERCY_DISABLE_DIRECT_UPLOAD forces every resource through legacy even when URLs are returned', async () => { + gcsReply.and.callFake(() => [200]); + api.reply('/builds/123/snapshots', ({ body }) => [201, withSignedUrls(body)]); + + let content = 'forced-legacy'; + process.env.PERCY_DISABLE_DIRECT_UPLOAD = '1'; + try { + await expectAsync( + client.sendSnapshot(123, { + name: 'forced-legacy', + resources: [{ + url: '/x', sha: sha256hash(content), content, mimetype: 'text/plain', + md5: md5base64(content), contentLength: Buffer.byteLength(content, 'utf-8') + }] + }) + ).toBeResolved(); + + expect(gcsReply).not.toHaveBeenCalled(); + expect(api.requests['/builds/123/resources'].length).toBe(1); + } finally { + delete process.env.PERCY_DISABLE_DIRECT_UPLOAD; + } + }); + + it('falls back to legacy when the direct PUT hits a transport error (e.g., 403)', async () => { + gcsReply.and.callFake(() => [403, 'expired']); + api.reply('/builds/123/snapshots', ({ body }) => [201, withSignedUrls(body)]); + + let content = 'falls-back'; + await expectAsync( + client.sendSnapshot(123, { + name: 'fallback', + resources: [{ + url: '/x', sha: sha256hash(content), content, mimetype: 'text/plain', + md5: md5base64(content), contentLength: Buffer.byteLength(content, 'utf-8'), root: true + }] + }) + ).toBeResolved(); + + expect(gcsReply).toHaveBeenCalled(); + expect(api.requests['/builds/123/resources']).toBeDefined(); + expect(api.requests['/builds/123/resources'][0].body.data.id).toBe(sha256hash(content)); + expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); + }); + + it('propagates correctness failures (400 BadDigest) and does not fall back to legacy', async () => { + gcsReply.and.callFake(() => [400, 'BadDigest']); + api.reply('/builds/123/snapshots', ({ body }) => [201, withSignedUrls(body)]); + + let content = 'bad-digest'; + await expectAsync( + client.sendSnapshot(123, { + name: 'fail-loud', + resources: [{ + url: '/x', sha: sha256hash(content), content, mimetype: 'text/plain', + md5: md5base64(content), contentLength: Buffer.byteLength(content, 'utf-8') + }] + }) + ).toBeRejected(); + + expect(api.requests['/builds/123/resources']).toBeUndefined(); + }); + }); }); describe('#createComparison()', () => { From 861047bafe13e7d3743b0be22f36d81249add3cb Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 11 May 2026 21:54:09 +0530 Subject: [PATCH 6/8] test(core): end-to-end direct bucket upload through @percy/core (PPLT-5303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stands up a fake GCS endpoint in-test via mockRequests('storage.googleapis.com'), mocks the percy-api snapshot-creation response to embed signed-upload-url in missing-resources attributes, and drives a real percy.snapshot(...) end-to-end. Four scenarios covered: 1. Happy path — every missing resource PUT direct to GCS with valid Content-MD5/Length headers; legacy POST /resources never touched; snapshot finalizes. 2. Transport-class failure — GCS returns 403 on every PUT; every resource falls back through legacy and the build still succeeds. 3. Customer escape hatch — PERCY_DISABLE_DIRECT_UPLOAD=1 suppresses the capability header and routes everything through legacy even when the server returns signed URLs. 4. Integrity — the Content-MD5 each PUT sends matches the content-md5 the CLI declared on snapshot creation for that same sha. Guards against buffer-mutation drift that would otherwise produce 400 BadDigest in prod. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/test/snapshot.test.js | 135 +++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index b8dbebad2..50ac5a406 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -1,5 +1,5 @@ import { sha256hash, base64encode } from '@percy/client/utils'; -import { logger, api, setupTest, createTestServer, dedent } from './helpers/index.js'; +import { logger, api, mockRequests, setupTest, createTestServer, dedent } from './helpers/index.js'; import { waitFor } from '@percy/core/utils'; import Percy from '@percy/core'; import { handleSyncJob } from '../src/snapshot.js'; @@ -2069,6 +2069,139 @@ describe('Snapshot', () => { }); }); }); + + describe('direct bucket upload (end-to-end)', () => { + const GCS_BASE = 'https://storage.googleapis.com'; + let gcsReply; + + function withSignedUrls(reqBody) { + return [201, { + data: { + id: '4567', + attributes: reqBody.attributes, + relationships: { + 'missing-resources': { + data: reqBody.data.relationships.resources.data.map(({ id }) => ({ + id, + attributes: { + 'signed-upload-url': `${GCS_BASE}/percy-resources/ns_gid_${id}?sig=fake` + } + })) + } + } + } + }]; + } + + beforeEach(async () => { + gcsReply = await mockRequests(GCS_BASE); + }); + + it('PUTs DOM + asset bytes direct to GCS when the API hands back signed URLs, and finalizes the snapshot', async () => { + gcsReply.and.callFake(() => [200]); + api.reply('/builds/123/snapshots', ({ body }) => withSignedUrls(body)); + + await percy.snapshot({ + name: 'direct-upload-e2e', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + await percy.idle(); + + // capability header reached percy-api on snapshot creation + expect(api.requests['/builds/123/snapshots'][0].headers['X-Percy-Capabilities']) + .toBe('direct-upload-v1'); + + // every declared resource carried content-md5 + content-length + const declared = api.requests['/builds/123/snapshots'][0].body.data.relationships.resources.data; + for (const entry of declared) { + expect(entry.attributes['content-md5']).toEqual(jasmine.any(String)); + expect(entry.attributes['content-length']).toEqual(jasmine.any(Number)); + } + + // every missing resource PUT to GCS (one call per resource) + expect(gcsReply).toHaveBeenCalledTimes(declared.length); + + // each PUT carried Content-MD5 and Content-Length + const puts = gcsReply.calls.allArgs().map(([req]) => req); + for (const put of puts) { + expect(put.method).toBe('PUT'); + expect(put.headers['Content-MD5']).toEqual(jasmine.any(String)); + expect(put.headers['Content-Length']).toEqual(jasmine.any(Number)); + } + + // when direct succeeds, legacy POST /resources path is NOT used + expect(api.requests['/builds/123/resources']).toBeUndefined(); + + // snapshot still finalizes + expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); + }); + + it('falls back to legacy POST /resources for any resource that fails the direct PUT (transport-class)', async () => { + api.reply('/builds/123/snapshots', ({ body }) => withSignedUrls(body)); + gcsReply.and.callFake(() => [403, 'expired']); + + await percy.snapshot({ + name: 'fallback-e2e', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + await percy.idle(); + + expect(gcsReply).toHaveBeenCalled(); + + const declared = api.requests['/builds/123/snapshots'][0].body.data.relationships.resources.data; + expect(api.requests['/builds/123/resources']).toBeDefined(); + expect(api.requests['/builds/123/resources'].length).toBe(declared.length); + + expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); + }); + + it('PERCY_DISABLE_DIRECT_UPLOAD forces every resource through legacy end-to-end', async () => { + process.env.PERCY_DISABLE_DIRECT_UPLOAD = '1'; + api.reply('/builds/123/snapshots', ({ body }) => withSignedUrls(body)); + + try { + await percy.snapshot({ + name: 'forced-legacy-e2e', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + await percy.idle(); + + expect(api.requests['/builds/123/snapshots'][0].headers['X-Percy-Capabilities']).toBeUndefined(); + expect(gcsReply).not.toHaveBeenCalled(); + + const declared = api.requests['/builds/123/snapshots'][0].body.data.relationships.resources.data; + expect(api.requests['/builds/123/resources'].length).toBe(declared.length); + } finally { + delete process.env.PERCY_DISABLE_DIRECT_UPLOAD; + } + }); + + it('Content-MD5 sent to GCS matches the declared md5 from snapshot creation (integrity end-to-end)', async () => { + gcsReply.and.callFake(() => [200]); + api.reply('/builds/123/snapshots', ({ body }) => withSignedUrls(body)); + + await percy.snapshot({ + name: 'integrity-e2e', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + await percy.idle(); + + // declared md5 keyed by sha (which is the resource id and also embedded + // in the signed-upload URL path) + const declared = api.requests['/builds/123/snapshots'][0].body.data.relationships.resources.data; + const md5BySha = Object.fromEntries(declared.map(e => [e.id, e.attributes['content-md5']])); + + for (const [req] of gcsReply.calls.allArgs()) { + const m = req.path.match(/_(?[a-f0-9]{64})/); + expect(m).withContext(`PUT path missing sha: ${req.path}`).not.toBeNull(); + expect(req.headers['Content-MD5']).toBe(md5BySha[m.groups.sha]); + } + }); + }); }); // ── runDoctorOnFailure ──────────────────────────────────────────────────────── From 3fc83f62ef2d9aa3f9b11b967176da3af8dc5346 Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 18 May 2026 11:00:03 +0530 Subject: [PATCH 7/8] fix(tests): lint + cli-upload expectations for direct-upload metadata (PPLT-5303) - client.test.js: split multi-property lines flagged by object-property-newline - cli-upload/upload.test.js: expect new content-md5/content-length attributes on snapshot resources now that createSnapshot declares them Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli-upload/test/upload.test.js | 8 ++++++-- packages/client/test/client.test.js | 25 +++++++++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/cli-upload/test/upload.test.js b/packages/cli-upload/test/upload.test.js index 503796e7b..e4aaa8afe 100644 --- a/packages/cli-upload/test/upload.test.js +++ b/packages/cli-upload/test/upload.test.js @@ -108,7 +108,9 @@ describe('percy upload', () => { 'resource-url': 'http://local/test-1', mimetype: 'text/html', 'for-widths': null, - 'is-root': true + 'is-root': true, + 'content-md5': jasmine.any(String), + 'content-length': jasmine.any(Number) } }, { type: 'resources', @@ -117,7 +119,9 @@ describe('percy upload', () => { 'resource-url': 'http://local/test-1.png', mimetype: 'image/png', 'for-widths': null, - 'is-root': null + 'is-root': null, + 'content-md5': jasmine.any(String), + 'content-length': jasmine.any(Number) } }]) } diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 2dc32d4d0..6335d19b1 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1772,8 +1772,12 @@ describe('PercyClient', () => { client.sendSnapshot(123, { name: 'forced-legacy', resources: [{ - url: '/x', sha: sha256hash(content), content, mimetype: 'text/plain', - md5: md5base64(content), contentLength: Buffer.byteLength(content, 'utf-8') + url: '/x', + sha: sha256hash(content), + content, + mimetype: 'text/plain', + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8') }] }) ).toBeResolved(); @@ -1794,8 +1798,13 @@ describe('PercyClient', () => { client.sendSnapshot(123, { name: 'fallback', resources: [{ - url: '/x', sha: sha256hash(content), content, mimetype: 'text/plain', - md5: md5base64(content), contentLength: Buffer.byteLength(content, 'utf-8'), root: true + url: '/x', + sha: sha256hash(content), + content, + mimetype: 'text/plain', + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8'), + root: true }] }) ).toBeResolved(); @@ -1815,8 +1824,12 @@ describe('PercyClient', () => { client.sendSnapshot(123, { name: 'fail-loud', resources: [{ - url: '/x', sha: sha256hash(content), content, mimetype: 'text/plain', - md5: md5base64(content), contentLength: Buffer.byteLength(content, 'utf-8') + url: '/x', + sha: sha256hash(content), + content, + mimetype: 'text/plain', + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8') }] }) ).toBeRejected(); From 39dd38f23471fd0aa232dd7b12172bad2604c1b2 Mon Sep 17 00:00:00 2001 From: Manoj-Katta Date: Mon, 18 May 2026 11:12:01 +0530 Subject: [PATCH 8/8] test(client): cover orphan missing-resource SHA branch (PPLT-5303) The safety guard at sendSnapshot's missing-resources loop (resources[entry.id] returning undefined when the API echoes a SHA we never sent) was uncovered, dropping @percy/client below the 100% coverage threshold. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/test/client.test.js | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 6335d19b1..b9185333c 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1836,6 +1836,37 @@ describe('PercyClient', () => { expect(api.requests['/builds/123/resources']).toBeUndefined(); }); + + it('skips missing-resource entries whose SHA was not sent in the request', async () => { + api.reply('/builds/123/snapshots', () => [201, { + data: { + id: '4567', + relationships: { + 'missing-resources': { + data: [{ id: 'orphan-sha-not-in-request' }] + } + } + } + }]); + + let content = 'orphan'; + await expectAsync( + client.sendSnapshot(123, { + name: 'orphan', + resources: [{ + url: '/x', + sha: sha256hash(content), + content, + mimetype: 'text/plain', + md5: md5base64(content), + contentLength: Buffer.byteLength(content, 'utf-8') + }] + }) + ).toBeResolved(); + + expect(gcsReply).not.toHaveBeenCalled(); + expect(api.requests['/builds/123/resources']).toBeUndefined(); + }); }); });