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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"@percy/config": "^1.31.10",
"axios": "1.17.0",
"cross-spawn": "^7.0.3",
"form-data": "4.0.5",
"glob-to-regexp": "^0.4.1",
"qs": "^6.11.0",
"react-router-dom": "7.13.1"
Expand Down
6 changes: 6 additions & 0 deletions src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,10 @@ export const flags = [{
name: 'partial',
description: 'Marks the build as a partial build',
validate: () => (process.env.PERCY_PARTIAL_BUILD ||= '1')
}, {
name: 'upload-bundle',
description: 'Upload the built Storybook (directory mode) to Percy for per-build hosting',
percyrc: 'storybook.uploadBundle',
type: 'boolean',
default: false
}];
7 changes: 7 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ export const configSchema = {
}
],
properties: {
// Opt-in to per-build Storybook hosting (PER-8973). When true (and running in
// directory mode), the SDK uploads the built storybook-static bundle to Percy after
// the snapshot run. Default off so an SDK upgrade never changes upload behavior.
uploadBundle: {
type: 'boolean',
default: false
},
docs: {
type: 'object',
default: {},
Expand Down
31 changes: 28 additions & 3 deletions src/snapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ function shardSnapshots(snapshots, { shardSize, shardCount, shardIndex }) {
return snapshots.splice(size * shardIndex, size);
}

// Recursively encode booleans in Storybook's canonical `!true`/`!false` URL form.
// encodeStoryArgs leaves booleans as-is (fine for capture, which applies args via
// channel events), but a URL deep-link needs the banged form to decode to a boolean.
function bangBooleans(value) {
if (typeof value === 'boolean') return `!${value}`;
if (Array.isArray(value)) return value.map(bangBooleans);
if (value && Object.getPrototypeOf(value) === Object.prototype) {
return Object.entries(value).reduce((acc, [k, v]) => (
Object.assign(acc, { [k]: bangBooleans(v) })
), {});
}
return value;
}

// Transforms a set of pre-encoded args into a single query parameter value
function buildStorybookArgsParam(args) {
let argsParam = qs.stringify(args, {
Expand Down Expand Up @@ -231,14 +245,25 @@ function mapStorybookSnapshots(stories, { previewUrl, flags, config, globalDocSe

// remove filter options and generate story snapshot URLs
return snapshots.map(({ skip, include, exclude, ...story }) => {
let argsParam = story.args && buildStorybookArgsParam(story.args);
let globalsParam = story.globals && buildStorybookArgsParam(story.globals);
let url = `${previewUrl}?id=${story.id}`;
if (story.args) url += `&args=${buildStorybookArgsParam(story.args)}`;
if (story.globals) url += `&globals=${buildStorybookArgsParam(story.globals)}`;
if (argsParam) url += `&args=${argsParam}`;
if (globalsParam) url += `&globals=${globalsParam}`;
for (let [k, v] of Object.entries(story.queryParams ?? {})) url += `&${k}=${v}`;
if (!story.queryParams?.viewMode) {
url += `&viewMode=${viewModeFor(story)}`;
}
return Object.assign(story, { url });
// Carry the story identity with the snapshot (see the `storybook` property of the
// snapshot schema in @percy/core). Persisted by the API so the review UI can
// deep-link the hosted bundle to the exact variant this snapshot captured.
// Booleans use Storybook's canonical `!true`/`!false` URL form — the manager
// decodes those to real booleans, while a plain `false` would apply as the
// truthy string "false".
let storybook = { id: story.id };
if (story.args) storybook.args = buildStorybookArgsParam(bangBooleans(story.args));
if (story.globals) storybook.globals = buildStorybookArgsParam(bangBooleans(story.globals));
return Object.assign(story, { url, storybook });
});
}

Expand Down
17 changes: 16 additions & 1 deletion src/storybook.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const storybook = command('storybook', {
StorybookConfig.configSchema
]
}
}, async function*({ percy, args, flags, exit }) {
}, async function*({ percy, args, flags, exit, log }) {
if (!percy) exit(0, 'Percy is disabled');
let { takeStorybookSnapshots } = yield import('./snapshots.js');
let { createServer } = yield import('@percy/cli-command/utils');
Expand All @@ -43,6 +43,21 @@ export const storybook = command('storybook', {
baseUrl: args.url ?? server?.address(),
flags
});

// Per-build Storybook hosting (PER-8973). Opt-in via `storybook.uploadBundle`
// (.percy.yml) or `--upload-bundle`, directory mode only (URL mode has no on-disk
// bundle). Best-effort: it runs after the snapshot run is the source of truth and can
// never fail the build.
if (args.serve && percy.config.storybook?.uploadBundle) {
yield* percy.yield.flush(); // ensure the build has been created (delayUploads: true)
let { uploadStorybookBundle } = yield import('./upload-bundle.js');
yield uploadStorybookBundle({
percy,
log,
directory: args.serve,
buildId: percy.build?.id
});
}
});

export default storybook;
196 changes: 196 additions & 0 deletions src/upload-bundle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Per-build Storybook hosting upload (PER-8973) — content-addressable, via the verifier
* middleman (Flow B).
*
* Flow (see the PER-8973 design comments):
* 1. Walk the built storybook-static dir, SHA-256 every file -> manifest [{path, sha, size}].
* 2. POST /check -> the API returns the MISSING shas + the verifier upload URL + a
* short-lived upload token (dedup: already-stored files are skipped).
* 3. POST each missing file's bytes to the verifier (bounded parallelism), with the upload
* token + declared sha. The verifier re-hashes and writes the CAS blob only if it matches.
* Bytes never touch percy-api; nothing unverified reaches GCS.
* 4. POST /commit with the manifest (path -> sha) to finalize the build.
*
* Talks to percy-api via axios (percy.client.post is JSON-only), reusing percy.client.token
* + apiUrl. Best-effort: every failure is logged and swallowed; on terminal failure a
* beacon marks the bundle failed so it surfaces on the review page. Never throws / exits nonzero.
*/

import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import axios from 'axios';

export const MAX_FILE_BYTES = 50 * 1024 * 1024; // per-file cap (matches API MAX_FILE_BYTES)
export const MAX_BUNDLE_BYTES = 1024 * 1024 * 1024; // per-build total cap
export const MAX_FILE_COUNT = 5000;
const UPLOAD_CONCURRENCY = 12;

function sha256Hex(buf) {
return createHash('sha256').update(buf).digest('hex');
}

// Recursively list files under dir as { relPath (posix), absPath, size }.
function walkFiles(dir) {
const out = [];
const walk = (abs, rel) => {
for (const entry of readdirSync(abs, { withFileTypes: true })) {
const childAbs = path.join(abs, entry.name);
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(childAbs, childRel);
} else if (entry.isFile()) {
out.push({ relPath: childRel, absPath: childAbs, size: statSync(childAbs).size });
}
// symlinks/other types are skipped — only regular files are hosted
}
};
walk(dir, '');
return out;
}

// Build the manifest by hashing every file. Returns { entries, totalBytes }.
function buildManifest(dir) {
const entries = walkFiles(dir).map(f => {
const bytes = readFileSync(f.absPath);
return { path: f.relPath, sha256: sha256Hex(bytes), size: f.size, absPath: f.absPath };
});
const totalBytes = entries.reduce((n, e) => n + e.size, 0);
return { entries, totalBytes };
}

function endpoint(apiUrl, buildId, suffix) {
return `${apiUrl}/builds/${buildId}/storybook_bundle${suffix}`;
}

function authHeaders(token, extra = {}) {
return { ...extra, Authorization: `Token token=${token}` };
}

async function sendFailureBeacon({ apiUrl, token, buildId, reason, log }) {
try {
const FormData = (await import('form-data')).default;
const form = new FormData();
form.append('state_hint', 'failed');
form.append('reason', reason);
await axios.post(endpoint(apiUrl, buildId, ''), form, {
headers: authHeaders(token, form.getHeaders())
});
} catch {
log?.debug?.('Storybook bundle failure beacon could not be delivered');
}
}

// Run tasks with bounded concurrency; rejects on the first failure.
async function runBounded(items, limit, fn) {
const queue = [...items];
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length) {
const item = queue.shift();
await fn(item);
}
});
await Promise.all(workers);
}

/**
* @param {Object} args
* @param {Object} args.percy percy core instance (.client has apiUrl + token)
* @param {Object} args.log logger (info/warn/debug)
* @param {string} args.directory absolute path to the static Storybook build dir
* @param {string|number} args.buildId
* @param {Object} [args.caps] override {maxFileBytes, maxBundleBytes, maxFileCount} (tests)
*/
export async function uploadStorybookBundle({ percy, log, directory, buildId, caps = {} }) {
const maxFileBytes = caps.maxFileBytes ?? MAX_FILE_BYTES;
const maxBundleBytes = caps.maxBundleBytes ?? MAX_BUNDLE_BYTES;
const maxFileCount = caps.maxFileCount ?? MAX_FILE_COUNT;
const apiUrl = percy?.client?.apiUrl;
// Resolve the token the same way the client does for requests: explicit token, then
// PERCY_TOKEN env (this.env.token), then config. Reading the raw `client.token` misses the
// common env case (token only lives in this.env.token) and silently skips the upload.
const token = percy?.client?.getToken?.(false) ?? percy?.client?.token;
if (!apiUrl || !token || !buildId || !directory) {
log?.warn?.(
'Storybook bundle upload skipped: missing ' +
`apiUrl=${!!apiUrl} token=${!!token} buildId=${!!buildId} directory=${!!directory}`
);
return;
}

let entries, totalBytes;
try {
({ entries, totalBytes } = buildManifest(directory));
} catch (err) {
log?.warn?.(`Storybook bundle upload skipped: could not read build dir: ${err.message}`);
await sendFailureBeacon({ apiUrl, token, buildId, reason: 'client_failed', log });
return;
}

// Client-side quota pre-check (the API enforces the same before signing).
const oversize = entries.find(e => e.size > maxFileBytes);
if (entries.length > maxFileCount || totalBytes > maxBundleBytes || oversize) {
log?.warn?.(
'Storybook bundle exceeds hosting limits ' +
`(${entries.length} files, ${Math.round(totalBytes / 1024 / 1024)} MB) and was not uploaded. ` +
'Trim addons or large static assets, or contact support to raise the limit.'
);
await sendFailureBeacon({ apiUrl, token, buildId, reason: 'size_cap', log });
return;
}

try {
// (1) /check — one call: which shas are missing + the verifier upload URL + upload token.
const checkRes = await axios.post(
endpoint(apiUrl, buildId, '/check'),
{ files: entries.map(e => ({ path: e.path, sha256: e.sha256, size: e.size })) },
{ headers: authHeaders(token) }
);
const missing = checkRes.data?.missing || [];
const uploadUrl = checkRes.data?.upload_url;
const uploadToken = checkRes.data?.upload_token;
const bySha = new Map(entries.map(e => [e.sha256, e]));

if (missing.length && (!uploadUrl || !uploadToken)) {
throw new Error('Storybook /check did not return an upload endpoint (upload_url/upload_token)');
}

// (2)+(3) POST each missing blob to the verifier middleman (Flow B). It re-hashes the bytes
// and writes them to the content-addressed store only if they match — bytes never touch the
// API, and nothing unverified reaches GCS.
await runBounded(missing, UPLOAD_CONCURRENCY, async ({ sha256 }) => {
const entry = bySha.get(sha256);
if (!entry) return;
await axios.post(uploadUrl, readFileSync(entry.absPath), {
headers: {
'X-Upload-Token': uploadToken,
'X-Expected-Sha': sha256,
'Content-Type': 'application/octet-stream'
},
maxContentLength: Infinity,
maxBodyLength: Infinity
});
});

// (4) /commit — finalize the build with the manifest.
await axios.post(
endpoint(apiUrl, buildId, '/commit'),
{ manifest: entries.map(e => ({ path: e.path, sha256: e.sha256 })) },
{ headers: authHeaders(token) }
);

log?.info?.(`Uploaded Storybook bundle for build #${buildId} (${entries.length} files, ${missing.length} new)`);
} catch (err) {
const status = err.response?.status;
const detail = status ? `HTTP ${status}` : err.message;
// A 403 feature_disabled is terminal but not a client upload failure worth a beacon.
if (status === 403) {
log?.warn?.(`Storybook bundle upload skipped: ${detail}`);
return;
}
log?.warn?.(`Storybook bundle upload failed: ${detail}`);
await sendFailureBeacon({ apiUrl, token, buildId, reason: 'client_failed', log });
}
}

export default uploadStorybookBundle;
3 changes: 2 additions & 1 deletion test/.storybook/args.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export default {
hsla: 'hsla(120, 80%, 30%, .5)',
shortHex: '#c6c',
longHex: '#a907cf',
alphaHex: '#a907cf9f'
alphaHex: '#a907cf9f',
bool: true
}
}]
}
Expand Down
23 changes: 22 additions & 1 deletion test/storybook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,27 @@ describe('percy storybook', () => {
expect(logger.stderr).toEqual([]);
});

it('attaches the story identity to each snapshot', async () => {
// eslint-disable-next-line import/no-extraneous-dependencies
let { Percy } = await import('@percy/core');
spyOn(Percy.prototype, 'snapshot').and.callThrough();

await storybook(['http://localhost:9000', '--dry-run', '--include=Args']);

let options = Percy.prototype.snapshot.calls.allArgs().flat().flat();
let byName = Object.fromEntries(options.map(o => [o.name, o.storybook]));

// plain story: id only
expect(byName.Args).toEqual({ id: 'args--args' });
// additionalSnapshots variant: same id, its own encoded args
expect(byName['Custom Args']).toEqual({
id: 'args--args',
args: 'text:Snapshot+custom+args;style.font:1rem+sans-serif'
});
// booleans use Storybook's canonical !true/!false URL form
expect(byName['Special Args'].args).toContain('bool:!true');
});

it('excludes stories from snapshots with --exclude', async () => {
// Args and Mixed are excluded by default via story-level exclude, but global
// --exclude switches shouldSkipStory to use config filters and disregards
Expand Down Expand Up @@ -579,7 +600,7 @@ describe('percy storybook', () => {
'date:!date(2022-01-01T00:00:00.000Z);' +
'rgb:!rgb(20,30,40);rgba:!rgba(20,30,40,.5);' +
'hsl:!hsl(120,80,30);hsla:!hsla(120,80,30,.5);' +
'shortHex:!hex(c6c);longHex:!hex(a907cf);alphaHex:!hex(a907cf9f)&viewMode=story'
'shortHex:!hex(c6c);longHex:!hex(a907cf);alphaHex:!hex(a907cf9f);bool:true&viewMode=story'
]));
});

Expand Down
Loading
Loading