Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support only testing components affected by recent git changes #304

Merged
merged 83 commits into from Apr 29, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
eefd42a
Add --only-changed feature
ghengeveld Mar 10, 2021
0ed090a
Skip onlyStoryFiles
ghengeveld Mar 11, 2021
98b1562
Reword
ghengeveld Mar 11, 2021
779b1f2
Preserve missing specs when running with --only-changed
ghengeveld Mar 11, 2021
3c82255
Merge branch 'next' into determine-affected-stories
ghengeveld Mar 25, 2021
228fb24
Add some helper/debug scripts
ghengeveld Mar 30, 2021
68b4fee
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 8, 2021
81ce9e4
Trim the final output some more
ghengeveld Apr 8, 2021
9acd377
Log the target filename
ghengeveld Apr 8, 2021
49d09c2
Omit webpack stuff
ghengeveld Apr 8, 2021
dc33762
ESM
ghengeveld Apr 8, 2021
bef92a9
Rename file and add script
ghengeveld Apr 8, 2021
ebfd5b3
Rename file and add script
ghengeveld Apr 8, 2021
51514a5
Pass changedFiles as args
ghengeveld Apr 8, 2021
feb1c0d
Cleanup
ghengeveld Apr 8, 2021
aa1010b
Use real configDir and staticDir + big refactor
ghengeveld Apr 8, 2021
7e81012
Move dependency tracing to upload step so we can skip upload if nothi…
ghengeveld Apr 9, 2021
516dd7d
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 9, 2021
51ee57b
Deal with zero changed files since baseline commit(s), in conjunction…
ghengeveld Apr 9, 2021
3cbf88b
Update bin/lib/parseArgs.js
ghengeveld Apr 9, 2021
4e71b5a
Update bin/lib/parseArgs.js
ghengeveld Apr 9, 2021
4c66cc7
Fix tests
ghengeveld Apr 9, 2021
2639b3d
Rename getBaselineCommits to getParentCommits
ghengeveld Apr 9, 2021
176b5e8
Rename baselineCommits to parentCommits
ghengeveld Apr 9, 2021
b3d3539
Rewording
ghengeveld Apr 9, 2021
35d57b0
5.7.2-canary.0
ghengeveld Apr 12, 2021
56592de
Pass --webpack-stats-json to build-storybook
ghengeveld Apr 12, 2021
7e5007c
5.7.2-canary.1
ghengeveld Apr 12, 2021
06e9884
Check Storybook version before using --webpack-stats-json flag
ghengeveld Apr 12, 2021
eba9c79
Fix messaging
ghengeveld Apr 12, 2021
290af6d
5.7.2-canary.2
ghengeveld Apr 12, 2021
7dba03b
Fix messaging
ghengeveld Apr 12, 2021
c7853fd
5.7.2-canary.3
ghengeveld Apr 12, 2021
24bc6b5
Catch invalid commit and warn about it
ghengeveld Apr 15, 2021
3da983d
Change wording for snapshot progress message
ghengeveld Apr 15, 2021
6852874
Warn when using --only-changed without stats file
ghengeveld Apr 15, 2021
b1d5cdf
Update tests
ghengeveld Apr 15, 2021
c939a19
Don't infer preserveMissingSpecs on --only or --only-changed
ghengeveld Apr 15, 2021
6a5a56f
Run skip mutation when there's no changedFiles
ghengeveld Apr 15, 2021
db8ead2
Better messaging when bailing on --only-changed
ghengeveld Apr 15, 2021
bda4f29
5.7.2-canary.4
ghengeveld Apr 15, 2021
7c2fc81
Use actual baseline commits from which to determine changed files
ghengeveld Apr 15, 2021
782592c
Fix skipped count
ghengeveld Apr 16, 2021
149ced6
Fix baseline commits retrieval
ghengeveld Apr 16, 2021
e4de04f
Narrow down the try/catch so we don't catch too much
ghengeveld Apr 16, 2021
d5219c0
Support prerelease versions
ghengeveld Apr 16, 2021
61d752f
onlyStoryFiles is an object
ghengeveld Apr 16, 2021
c61894c
Not always due to --skip
ghengeveld Apr 16, 2021
3339bda
We don't need manager-stats
ghengeveld Apr 16, 2021
18b0a51
Better logs
ghengeveld Apr 16, 2021
1e17d11
Log fixes
ghengeveld Apr 16, 2021
344b9ab
Reword final status messages
ghengeveld Apr 17, 2021
0d4d613
Fix upload success message
ghengeveld Apr 17, 2021
a1a0c44
Fix upload success message
ghengeveld Apr 17, 2021
5bf0e1a
Update stats reporting
ghengeveld Apr 17, 2021
9b3f004
5.7.2-canary.5
ghengeveld Apr 17, 2021
2b67c45
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 17, 2021
b305bb8
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 17, 2021
2274698
Upload may have been skipped
ghengeveld Apr 19, 2021
04e5ca3
5.7.2-canary.6
ghengeveld Apr 19, 2021
cac4ac5
Fix numbering
ghengeveld Apr 21, 2021
f50b263
5.7.2-canary.7
ghengeveld Apr 21, 2021
15ccedd
Support only and onlyChanged in GitHub Action
ghengeveld Apr 21, 2021
bf090c7
Run with --only-changed against staging
ghengeveld Apr 21, 2021
a2c565a
Fix changedFiles is undefined error
ghengeveld Apr 21, 2021
f7e3700
Bump storybook
ghengeveld Apr 21, 2021
de5bc45
Remove --webpack-stats-json flag
ghengeveld Apr 22, 2021
679278b
Better messaging
ghengeveld Apr 23, 2021
6b7ac73
Set proper exitCode and show suitable message when inheriting a build
ghengeveld Apr 23, 2021
5517fd2
5.7.2-canary.8
ghengeveld Apr 23, 2021
4a6a860
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 23, 2021
1781dc4
Fix story data
ghengeveld Apr 23, 2021
95f0f76
Update CI scripts
ghengeveld Apr 23, 2021
eb7a13d
Exit once uploaded
ghengeveld Apr 23, 2021
8260dc9
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 24, 2021
19fd509
Don't skip build when changedFiles or onlyStoryFiles is empty
ghengeveld Apr 26, 2021
470ba7d
5.7.2-canary.9
ghengeveld Apr 26, 2021
61b72f1
Look at package.json rather than node_modules to determine installed …
ghengeveld Apr 27, 2021
5c33cbc
5.7.2-canary.10
ghengeveld Apr 27, 2021
c12588d
Add addon-essentials
ghengeveld Apr 27, 2021
88f99d3
5.7.2-canary.11
ghengeveld Apr 27, 2021
862d33c
Fix tests
ghengeveld Apr 29, 2021
6ed15f2
Merge branch 'next' into determine-affected-stories
ghengeveld Apr 29, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions bin/git/git.js
Expand Up @@ -318,6 +318,12 @@ export async function getBaselineCommits(
];
}

export async function getChangedFiles(baseCommit, headCommit = '') {
// Note that an empty headCommit will include uncommitted (staged or unstaged) changes.
const files = await execGitCommand(`git diff --name-only ${baseCommit} ${headCommit}`);
return files.split(EOL).filter(Boolean);
}

/**
* Returns a boolean indicating whether the workspace is up-to-date (neither ahead nor behind) with
* the remote.
Expand Down
86 changes: 86 additions & 0 deletions bin/lib/getDependentStoryFiles.js
@@ -0,0 +1,86 @@
// Bail whenever one of these was changed
const GLOBALS = [/\/node_modules\//, /\/package\.json$/, /\/package-lock\.json$/, /\/yarn\.lock$/];

// Ignore these while tracing dependencies
const EXTERNALS = [/\/node_modules\//, /\/webpack\/runtime\//, /^\(webpack\)/];

const isGlobal = (name) => GLOBALS.some((re) => re.test(name));
const isUserCode = ({ name, moduleName }) => !EXTERNALS.some((re) => re.test(name || moduleName));

export function getDependentStoryFiles(ctx, stats, changedFiles) {
const { configDir = './.storybook', staticDir = [] } = ctx.storybook || {};

// TODO deal with Windows path separator
const storybookDir = configDir.startsWith('./') ? configDir : `./${configDir}`;
const staticDirs = staticDir.map((dir) => (dir.startsWith('./') ? dir : `./${dir}`));

// NOTE: this only works with `main:stories` -- if stories are imported from files in `.storybook/preview.js`
// we'll need a different approach to figure out CSF files (maybe the user should pass a glob?).
const storiesEntryFile = `${storybookDir}/generated-stories-entry.js`;

const idsByName = {};
const reasonsById = {};
const csfGlobsByName = {};

stats.modules.filter(isUserCode).forEach((mod) => {
if (mod.id) {
idsByName[mod.name] = mod.id;
(mod.modules ? mod.modules.map((m) => m.name) : []).forEach((name) => {
idsByName[name] = mod.id;
});
}

reasonsById[mod.id] = mod.reasons
.map((r) => r.moduleName)
.filter(Boolean)
.filter((n) => n !== mod.name);

if (reasonsById[mod.id].includes(storiesEntryFile)) {
csfGlobsByName[mod.name] = true;
}
});

const isCsfGlob = (name) => !!csfGlobsByName[name];
const isConfigFile = (name) => name.startsWith(storybookDir) && name !== storiesEntryFile;
const isStaticFile = (name) => staticDirs.some((dir) => name.startsWith(dir));

const changedCsfIds = new Set();
const checkedIds = {};
const toCheck = [];

let bail = changedFiles.find(isGlobal);

function traceName(name) {
if (bail || isCsfGlob(name)) return;
if (isConfigFile(name) || isStaticFile(name)) {
bail = name;
return;
}

const id = idsByName[name];
if (!id || !reasonsById[id] || checkedIds[id]) return;
toCheck.push(id);

if (reasonsById[id].some(isCsfGlob)) {
changedCsfIds.add(id);
}
}

changedFiles.forEach(traceName);
while (toCheck.length > 0) {
const id = toCheck.pop();
checkedIds[id] = true;
reasonsById[id].forEach(traceName);
}

if (bail) {
ctx.log.debug(`Ignoring --only-changed due to ${bail}`);
return false;
}

return Object.fromEntries(
stats.modules
.filter((mod) => changedCsfIds.has(mod.id))
.map((mod) => [String(mod.id), mod.name.replace(/ \+ \d+ modules$/, '')])
);
}
7 changes: 7 additions & 0 deletions bin/lib/getDependentStoryFiles.test.js
@@ -0,0 +1,7 @@
import { getDependentStoryFiles } from './getDependentStoryFiles';

describe('getDependentStoryFiles', () => {
it('traverses the reasons to find affected CSF files', () => {
getDependentStoryFiles([], { idsByName: {}, reasonsById: {}, isCsfGlob: {} });
});
});
7 changes: 6 additions & 1 deletion bin/lib/getOptions.js
Expand Up @@ -30,6 +30,7 @@ export default async function getOptions({ argv, env, flags, log, packageJson })
projectToken: takeLast(flags.projectToken || flags.appCode) || env.CHROMATIC_PROJECT_TOKEN, // backwards compatibility

only: flags.only,
onlyChanged: flags.onlyChanged === '' ? true : flags.onlyChanged,
list: flags.list,
fromCI,
skip: flags.skip === '' ? true : flags.skip,
Expand All @@ -41,7 +42,7 @@ export default async function getOptions({ argv, env, flags, log, packageJson })
exitZeroOnChanges: flags.exitZeroOnChanges === '' ? true : flags.exitZeroOnChanges,
exitOnceUploaded: flags.exitOnceUploaded === '' ? true : flags.exitOnceUploaded,
ignoreLastBuildOnBranch: flags.ignoreLastBuildOnBranch,
preserveMissingSpecs: flags.preserveMissing || !!flags.only,
preserveMissingSpecs: flags.preserveMissing || !!flags.only || flags.onlyChanged !== undefined,
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
originalArgv: argv,

buildScriptName: flags.buildScriptName,
Expand Down Expand Up @@ -115,6 +116,10 @@ export default async function getOptions({ argv, env, flags, log, packageJson })
throw new Error(invalidSingularOptions(foundSingularOpts.map((key) => singularOpts[key])));
}

if (options.only && options.onlyChanged) {
throw new Error(invalidSingularOptions(['--only', '--only-changed']));
}

// No need to start or build Storybook if we're going to fetch from a URL
if (storybookUrl) {
noStart = true;
Expand Down
29 changes: 22 additions & 7 deletions bin/lib/getStorybookInfo.js
@@ -1,6 +1,8 @@
// Figure out the Storybook version and view layer

import fs from 'fs-extra';
import meow from 'meow';
import argv from 'string-argv';

const viewLayers = [
'react',
Expand Down Expand Up @@ -73,7 +75,7 @@ const findViewlayer = async ({ env }) => {
if (!viewLayer || !version) {
throw new Error('CHROMATIC_STORYBOOK_VERSION was provided but could not be used');
}
return { viewLayer, version };
return { version, viewLayer };
}

// Try to find the Storybook viewlayer package
Expand All @@ -88,7 +90,7 @@ const findViewlayer = async ({ env }) => {
return Promise.race([
...findings.map((p, i) =>
p.then(
(l) => read(l).then((r) => ({ viewLayer: viewLayers[i], ...r })),
(l) => read(l).then(({ version }) => ({ version, viewLayer: viewLayers[i] })),
disregard // keep it pending forever
)
),
Expand All @@ -111,12 +113,25 @@ const findAddons = async () => {
return { addons: result.filter(Boolean) };
};

export default async function getStorybookInfo(ctx) {
const storybookInfo = await findViewlayer(ctx);
const addonInfo = await findAddons();
const findConfigFlags = async ({ options, packageJson }) => {
const { scripts = {} } = packageJson;
if (!options.buildScriptName || !scripts[options.buildScriptName]) return {};

const { flags } = meow({
argv: argv(scripts[options.buildScriptName]),
flags: {
configDir: { type: 'string', alias: 'c' },
staticDir: { type: 'string', alias: 's' },
},
});

return {
...storybookInfo,
...addonInfo,
configDir: flags.configDir,
staticDir: flags.staticDir && flags.staticDir.split(','),
};
};

export default async function getStorybookInfo(ctx) {
const info = await Promise.all([findAddons(), findConfigFlags(ctx), findViewlayer(ctx)]);
return info.reduce((acc, obj) => Object.assign(acc, obj), {});
}
4 changes: 3 additions & 1 deletion bin/lib/parseArgs.js
Expand Up @@ -21,7 +21,8 @@ export default function parseArgs(argv) {
--exit-once-uploaded [branch] Exit with 0 once the built version has been published to Chromatic. Only for [branch], if specified. Globs are supported via picomatch.
--exit-zero-on-changes [branch] If all snapshots render but there are visual changes, exit with code 0 rather than the usual exit code 1. Only for [branch], if specified. Globs are supported via picomatch.
--ignore-last-build-on-branch <branch> Do not use the last build on this branch as a baseline if it is no longer in history (i.e. branch was rebased). Globs are supported via picomatch.
--only <storypath> Only run a single story or a subset of stories. Story paths typically look like "Path/To/Story". Globs are supported via picomatch. This option implies --preserve-missing.
--only <storypath> Only run a single story or a subset of stories. Story paths typically look like "Path/To/Story". Globs are supported via picomatch. All other snapshots will be inherited from the prior commit.
--only-changed [branch] Only run stories affected by files changed since the baseline build. Only for [branch], if specified. Globs are supported via picomatch. All other snapshots will be inherited from the prior commit.
--patch-build <headbranch...basebranch> Create a patch build to fix a missing PR comparison.
--preserve-missing Treat missing stories as unchanged rather than deleted when comparing to the baseline.
--skip [branch] Skip Chromatic tests, but mark the commit as passing. Avoids blocking PRs due to required merge checks. Only for [branch], if specified. Globs are supported via picomatch.
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function parseArgs(argv) {
exitZeroOnChanges: { type: 'string' },
ignoreLastBuildOnBranch: { type: 'string' },
only: { type: 'string' },
onlyChanged: { type: 'string' },
branchName: { type: 'string' },
patchBuild: { type: 'string' },
preserveMissing: { type: 'boolean' },
Expand Down
2 changes: 1 addition & 1 deletion bin/tasks/build.js
@@ -1,5 +1,5 @@
import execa from 'execa';
import fs from 'fs';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import tmp from 'tmp-promise';
Expand Down
27 changes: 22 additions & 5 deletions bin/tasks/gitInfo.js
@@ -1,7 +1,7 @@
import picomatch from 'picomatch';

import { getCommitAndBranch } from '../git/getCommitAndBranch';
import { getBaselineCommits, getSlug, getVersion } from '../git/git';
import { getBaselineCommits, getChangedFiles, getSlug, getVersion } from '../git/git';
import { createTask, transitionTo } from '../lib/tasks';
import {
initial,
Expand Down Expand Up @@ -52,7 +52,8 @@ export const setGitInfo = async (ctx, task) => {
// The SkipBuildMutation ensures the commit is tagged properly.
if (await ctx.client.runQuery(TesterSkipBuildMutation, { commit })) {
ctx.skip = true;
return transitionTo(skippedForCommit, true)(ctx, task);
transitionTo(skippedForCommit, true)(ctx, task);
return;
}
throw new Error(skipFailed(ctx).output);
}
Expand All @@ -62,7 +63,7 @@ export const setGitInfo = async (ctx, task) => {
ignoreLastBuildOnBranch: matchesBranch(ctx.options.ignoreLastBuildOnBranch),
});
ctx.git.baselineCommits = baselineCommits;
ctx.log.debug(`Found baselineCommits: ${baselineCommits}`);
ctx.log.debug(`Found baselineCommits: ${baselineCommits.join(', ')}`);

// If the sole baseline is the most recent ancestor, then this is likely a rebuild (rerun of CI job).
// If the MRA is all green, there's no need to rerun the build, we just want the CLI to exit 0 so the CI job succeeds.
Expand All @@ -72,11 +73,27 @@ export const setGitInfo = async (ctx, task) => {
const mostRecentAncestor = await ctx.client.runQuery(TesterLastBuildQuery, { commit, branch });
if (mostRecentAncestor && ['PASSED', 'ACCEPTED'].includes(mostRecentAncestor.status)) {
ctx.skip = true;
return transitionTo(skippedRebuild, true)(ctx, task);
transitionTo(skippedRebuild, true)(ctx, task);
return;
}
}

return transitionTo(success, true)(ctx, task);
// Retrieve a list of changed file paths since the baseline commit(s), which will be used to
// determine affected story files later.
// In the unlikely scenario that this list is empty (and not a rebuild), we can skip the build
// completely, since we know for certain it wouldn't have any effect.
if (baselineCommits.length && matchesBranch(ctx.options.onlyChanged)) {
const results = await Promise.all(baselineCommits.map((c) => getChangedFiles(c)));
ctx.git.changedFiles = [...new Set(results.flat())].map((f) => `./${f}`);
ctx.log.debug(`Found changedFiles:\n${ctx.git.changedFiles.map((f) => ` ${f}`).join('\n')}`);
if (ctx.git.changedFiles.length === 0) {
ctx.skip = true;
transitionTo(skippedRebuild, true)(ctx, task);
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
return;
}
}

transitionTo(success, true)(ctx, task);
};

export default createTask({
Expand Down
3 changes: 1 addition & 2 deletions bin/tasks/storybookInfo.js
Expand Up @@ -3,8 +3,7 @@ import { createTask, transitionTo } from '../lib/tasks';
import { initial, pending, success } from '../ui/tasks/storybookInfo';

export const setStorybookInfo = async (ctx) => {
const { version, viewLayer, addons } = await getStorybookInfo(ctx);
ctx.storybook = { version, viewLayer, addons };
ctx.storybook = await getStorybookInfo(ctx);
};

export default createTask({
Expand Down