Skip to content
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@
"dependencies": {
"@amplitude/analytics-browser": "2.35.4",
"@amplitude/plugin-session-replay-browser": "1.25.16",
"@percy/cli-command": "^1.31.10",
"@percy/config": "^1.31.10",
"@percy/cli-command": "1.32.4",
"@percy/config": "1.32.4",
"axios": "1.17.0",
"cross-spawn": "^7.0.3",
"glob-to-regexp": "^0.4.1",
Expand Down
33 changes: 33 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,39 @@ export const configSchema = {
}
}
}
},
intelliStory: {
type: 'object',
unevaluatedProperties: false,
properties: {
enabled: {
type: 'boolean',
default: false
},
baseline: {
type: 'string'
},
untraced: {
type: 'array',
items: { type: 'string' }
},
trace: {
type: 'boolean',
default: false
},
bailOnChanges: {
type: 'array',
items: { type: 'string' }
},
statsFile: {
type: 'string',
default: 'enriched-stats.json'
},
failBuildOnFailure: {
type: 'boolean',
default: false
}
}
}
}
},
Expand Down
71 changes: 62 additions & 9 deletions src/snapshots.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { logger, PercyConfig } from '@percy/cli-command';
import { logger, PercyConfig, applyIntelliStory, writeIntelliStoryTrace, IntelliStoryBailError } from '@percy/cli-command';
import { yieldAll } from '@percy/cli-command/utils';
import qs from 'qs';
import {
Expand Down Expand Up @@ -70,12 +70,12 @@ function shouldSkipStory(name, options, config) {
// Returns snapshot config options for a Storybook story merged with global Storybook
// options. Validation error messages will be added to the provided validations set.
function getSnapshotConfig(story, config, invalid) {
let { id, ...options } = PercyConfig.migrate(story, '/storybook');
let { id, importPath, ...options } = PercyConfig.migrate(story, '/storybook');

let errors = PercyConfig.validate(options, '/storybook');
for (let e of (errors || [])) invalid.set(e.path, e.message);

return PercyConfig.merge([config, options, { id }], (path, prev, next) => {
return PercyConfig.merge([config, options, { id, importPath }], (path, prev, next) => {
// normalize, but do not merge include or exclude options
if (path.length === 1 && ['include', 'exclude'].includes(path[0])) {
return [path, [].concat(next).filter(Boolean)];
Expand Down Expand Up @@ -256,9 +256,8 @@ function needsFreshPage(previousStory) {
}

// Process a single story and capture its DOM
async function* processStory(page, story, previewResource, percy, flags, log) {
// Extract story details
let { id, args, globals, queryParams, ...options } = story;
export async function* processStory(page, story, previewResource, percy, flags, log) {
let { id, args, globals, queryParams, importPath, ...options } = story;

const enableJavaScript = options.enableJavaScript ?? percy.config.snapshot.enableJavaScript;
if (flags.dryRun || enableJavaScript) {
Expand All @@ -284,8 +283,26 @@ async function* processStory(page, story, previewResource, percy, flags, log) {
return options;
}

export async function applyIntelliStoryFilter(
percy, snapshots, intelliStoryConfig, buildDir, log, apply = applyIntelliStory
) {
if (!intelliStoryConfig?.enabled) return snapshots;

try {
return await apply(percy, snapshots, intelliStoryConfig, buildDir);
} catch (e) {
if (e instanceof IntelliStoryBailError) {
log.info(e.message);
} else {
log.warn(`IntelliStory failed (${e.message}); running full snapshot set`);
}
if (intelliStoryConfig.failBuildOnFailure) throw e;
return snapshots;
}
}

// Starts the percy instance and collects Storybook snapshots, calling the callback when done
export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags }) {
export async function* takeStorybookSnapshots(percy, callback, { baseUrl, buildDir, flags }) {
try {
let aboutUrl = new URL('?path=/settings/about', baseUrl).href;
let previewUrl = new URL('iframe.html', baseUrl).href;
Expand All @@ -303,6 +320,16 @@ export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags
yield percy.browser.launch();

const storybookConfig = percy.config.storybook;
const intelliStoryEnabled = !!storybookConfig?.intelliStory?.enabled;

// IntelliStory selects snapshots server-side against the real Percy build,
// so the build must exist before any snapshots are posted. Storybook delays
// uploads (the build is otherwise created lazily on the first flush), so
// create it up front here. Skipped on dry runs, where no build is created.
if (intelliStoryEnabled && !percy.dryRun) {
yield* percy.yield.startBuild();
}

const { isDocDiscoveryEnabled, isAutodocDiscoveryEnabled, globalDocSettings } = getDocCaptureFlagsWithRules(storybookConfig);

let [environmentInfo, stories] = yield* yieldAll([
Expand All @@ -314,14 +341,19 @@ export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags
evalStorybookStorySnapshots,
{
docCapture: isDocDiscoveryEnabled,
autodocCapture: isAutodocDiscoveryEnabled
autodocCapture: isAutodocDiscoveryEnabled,
intelliStory: !!storybookConfig?.intelliStory?.enabled
}
),
undefined,
{ from: 'preview url' }
)
]);

if (stories?.diagnostics) {
log.debug(`Story extraction diagnostics: ${JSON.stringify(stories.diagnostics)}`);
}

// map stories to snapshot options
let snapshots = mapStorybookSnapshots(stories, {
config: storybookConfig,
Expand All @@ -333,6 +365,8 @@ export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags
// set storybook environment info
percy.client.addEnvironmentInfo(environmentInfo);

snapshots = yield applyIntelliStoryFilter(percy, snapshots, storybookConfig?.intelliStory, buildDir, log);

// Track previous story state to determine when fresh pages are needed
let previousStory = null;

Expand Down Expand Up @@ -416,8 +450,27 @@ export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags
}
}

// Will stop once snapshots are done processing
// Will stop once snapshots are done processing (this finalizes the build)
yield* percy.yield.stop();

if (intelliStoryEnabled && !percy.dryRun && percy.build?.id) {
// Summarize the server-side selection outcome, derived from each snapshot
// create response (skipped-via-smartsnap) tallied in @percy/client.
const stats = percy.client.intelliStoryStats;
if (stats) {
const total = stats.kept + stats.skipped;
log.info(`IntelliStory: filtered out ${stats.skipped} of ${total} snapshots with no detected changes; the remaining ${stats.kept} were processed.`);
}

// After finalize, the IntelliStory graph job's data is available from job
// status, so fetch it once more and write the trace. Never let a trace
// failure fail an otherwise-successful build.
try {
yield writeIntelliStoryTrace(percy, storybookConfig.intelliStory, log);
} catch (e) {
log.debug(`IntelliStory: failed to write trace after finalize: ${e.message}`);
}
}
} catch (error) {
// force stop and re-throw
await percy.stop(true);
Expand Down
1 change: 1 addition & 0 deletions src/storybook.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const storybook = command('storybook', {

yield* takeStorybookSnapshots(percy, () => server?.close(), {
baseUrl: args.url ?? server?.address(),
buildDir: args.serve,
flags
});
});
Expand Down
64 changes: 58 additions & 6 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export function evalStorybookEnvironmentInfo({ waitForXPath }) {

// Evaluate and return serialized Storybook stories to snapshot
/* istanbul ignore next: no instrumenting injected code */
export function evalStorybookStorySnapshots({ waitFor }, { docCapture = false, autodocCapture = false } = {}) {
export function evalStorybookStorySnapshots({ waitFor }, { docCapture = false, autodocCapture = false, intelliStory = false } = {}) {
let serialize = (what, value, invalid) => {
if (what === 'include' || what === 'exclude') {
return [].concat(value).filter(Boolean).map(v => v.toString());
Expand Down Expand Up @@ -312,14 +312,64 @@ export function evalStorybookStorySnapshots({ waitFor }, { docCapture = false, a
});
}

const resolveImportPath = (s) => {
if (!s) return undefined;
if (entries && s.id && entries[s.id]?.importPath) return entries[s.id].importPath;
return s.parameters?.fileName || s.parameters?.__id || undefined;
};
const stampImportPath = list => {
if (!intelliStory) return list;
for (const s of list) {
const ip = resolveImportPath(s);
if (ip) s.importPath = ip;
}
return list;
};

const storiesObj = await (window.__STORYBOOK_PREVIEW__?.extract?.());
let stories;
let source;
if (storiesObj && !Array.isArray(storiesObj)) {
const stories = Object.values(storiesObj);
return stories.concat(docsEntries);
source = '__STORYBOOK_PREVIEW__.extract';
stories = stampImportPath(Object.values(storiesObj))
.concat(stampImportPath(docsEntries));
} else {
source = '__STORYBOOK_STORY_STORE__.raw';
await window.__STORYBOOK_STORY_STORE__?.extract?.();
stories = stampImportPath(window.__STORYBOOK_STORY_STORE__.raw());
}

if (intelliStory) {
const sampleEntry = entries
? (() => {
const firstId = Object.keys(entries)[0];
if (!firstId) return null;
const e = entries[firstId];
return { id: firstId, keys: Object.keys(e || {}), importPath: e?.importPath };
})()
: null;
const sampleStory = stories[0]
? {
id: stories[0].id,
importPath: stories[0].importPath,
parameterKeys: stories[0].parameters ? Object.keys(stories[0].parameters) : [],
fileName: stories[0].parameters?.fileName
}
: null;
const withImportPath = stories.filter(s => s.importPath).length;

stories.__intelliStoryDiagnostics = {
source,
entriesPresent: !!entries,
entriesCount: entries ? Object.keys(entries).length : 0,
storiesTotal: stories.length,
storiesWithImportPath: withImportPath,
sampleEntry,
sampleStory
};
}

await window.__STORYBOOK_STORY_STORE__?.extract?.();
return window.__STORYBOOK_STORY_STORE__.raw();
return stories;
}, 5000).catch(() => Promise.reject(new Error(
'Storybook object not found on the window. ' +
'Open Storybook and check the console for errors.'
Expand All @@ -330,13 +380,15 @@ export function evalStorybookStorySnapshots({ waitFor }, { docCapture = false, a
name: story.kind ? `${story.kind}: ${story.name}` : `${story.title}: ${story.name}`,
...story.parameters?.percy,
id: story.id,
...(intelliStory ? { importPath: story.importPath } : {}),
type: story.type ? story.type : 'story',
tags: story.tags
}, invalid));

return {
invalid: Array.from(invalid),
data
data,
...(intelliStory ? { diagnostics: stories.__intelliStoryDiagnostics } : {})
};
});
}
Expand Down
Loading
Loading