diff --git a/bin-src/trace.ts b/bin-src/trace.ts index 923b780c0..0bc11f720 100644 --- a/bin-src/trace.ts +++ b/bin-src/trace.ts @@ -1,5 +1,6 @@ import meow from 'meow'; +import { getRepositoryRoot } from '../node-src/git/git'; import { getDependentStoryFiles } from '../node-src/lib/getDependentStoryFiles'; import { isPackageManifestFile } from '../node-src/lib/utils'; import { readStatsFile } from '../node-src/tasks/readStatsFile'; @@ -91,6 +92,9 @@ export async function main(argv: string[]) { untraced: flags.untraced, traceChanged: flags.mode || true, }, + git: { + rootPath: await getRepositoryRoot(), + }, } as any; const stats = await readStatsFile(flags.statsFile); const changedFiles = input.map((f) => f.replace(/^\.\//, '')); diff --git a/node-src/git/execGit.test.ts b/node-src/git/execGit.test.ts new file mode 100644 index 000000000..81277224e --- /dev/null +++ b/node-src/git/execGit.test.ts @@ -0,0 +1,167 @@ +import { PassThrough, Transform } from 'node:stream'; +import { beforeEach } from 'node:test'; + +import { execaCommand as rawExecaCommand } from 'execa'; +import { describe, expect, it, vitest } from 'vitest'; + +import gitNoCommits from '../ui/messages/errors/gitNoCommits'; +import gitNotInitialized from '../ui/messages/errors/gitNotInitialized'; +import gitNotInstalled from '../ui/messages/errors/gitNotInstalled'; +import { execGitCommand, execGitCommandCountLines, execGitCommandOneLine } from './execGit'; + +vitest.mock('execa'); + +const execaCommand = vitest.mocked(rawExecaCommand); +beforeEach(() => { + execaCommand.mockReset(); +}); + +describe('execGitCommand', () => { + it('returns execa output if it works', async () => { + execaCommand.mockResolvedValue({ + all: Buffer.from('some output'), + } as any); + + expect(await execGitCommand('some command')).toEqual('some output'); + }); + + it('errors if there is no output', async () => { + execaCommand.mockResolvedValue({ + all: undefined, + } as any); + + await expect(execGitCommand('some command')).rejects.toThrow(/Unexpected missing git/); + }); + + it('handles missing git error', async () => { + execaCommand.mockRejectedValue(new Error('not a git repository')); + + await expect(execGitCommand('some command')).rejects.toThrow( + gitNotInitialized({ command: 'some command' }) + ); + }); + + it('handles git not found error', async () => { + execaCommand.mockRejectedValue(new Error('git not found')); + + await expect(execGitCommand('some command')).rejects.toThrow( + gitNotInstalled({ command: 'some command' }) + ); + }); + + it('handles no commits yet', async () => { + execaCommand.mockRejectedValue(new Error('does not have any commits yet')); + + await expect(execGitCommand('some command')).rejects.toThrow( + gitNoCommits({ command: 'some command' }) + ); + }); + + it('rethrows arbitrary errors', async () => { + execaCommand.mockRejectedValue(new Error('something random')); + await expect(execGitCommand('some command')).rejects.toThrow('something random'); + }); +}); + +function createExecaStreamer() { + let resolver; + let rejecter; + const promiseLike = new Promise((aResolver, aRejecter) => { + resolver = aResolver; + rejecter = aRejecter; + }) as Promise & { + stdout: Transform; + kill: () => void; + _rejecter: (err: Error) => void; + }; + promiseLike.stdout = new PassThrough(); + promiseLike.kill = resolver; + promiseLike._rejecter = rejecter; + return promiseLike; +} + +describe('execGitCommandOneLine', () => { + it('returns the first line if the command works', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandOneLine('some command'); + + streamer.stdout.write('First line\n'); + streamer.stdout.write('Second line\n'); + + expect(await promise).toEqual('First line'); + }); + + it('returns the output if the command only has one line', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandOneLine('some command'); + + streamer.stdout.write('First line\n'); + streamer.stdout.end(); + + expect(await promise).toEqual('First line'); + }); + + it('Return an error if the command has no ouput', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandOneLine('some command'); + + streamer.kill(); + + await expect(promise).rejects.toThrow(/missing git command output/); + }); + + it('rethrows arbitrary errors', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandOneLine('some command'); + + streamer._rejecter(new Error('some error')); + + await expect(promise).rejects.toThrow(/some error/); + }); +}); + +describe('execGitCommandCountLines', () => { + it('counts lines, many', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandCountLines('some command'); + + streamer.stdout.write('First line\n'); + streamer.stdout.write('Second line\n'); + streamer.kill(); + + expect(await promise).toEqual(2); + }); + + it('counts lines, one', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandCountLines('some command'); + + streamer.stdout.write('First line\n'); + streamer.kill(); + + expect(await promise).toEqual(1); + }); + + it('counts lines, none', async () => { + const streamer = createExecaStreamer(); + execaCommand.mockReturnValue(streamer as any); + + const promise = execGitCommandCountLines('some command'); + + streamer.kill(); + + expect(await promise).toEqual(0); + }); +}); diff --git a/node-src/git/execGit.ts b/node-src/git/execGit.ts new file mode 100644 index 000000000..7a9b57e6b --- /dev/null +++ b/node-src/git/execGit.ts @@ -0,0 +1,120 @@ +import { createInterface } from 'node:readline'; + +import { execaCommand } from 'execa'; + +import gitNoCommits from '../ui/messages/errors/gitNoCommits'; +import gitNotInitialized from '../ui/messages/errors/gitNotInitialized'; +import gitNotInstalled from '../ui/messages/errors/gitNotInstalled'; + +const defaultOptions: Parameters[1] = { + env: { LANG: 'C', LC_ALL: 'C' }, // make sure we're speaking English + timeout: 20_000, // 20 seconds + all: true, // interleave stdout and stderr + shell: true, // we'll deal with escaping ourselves (for now) +}; + +/** + * Execute a Git command in the local terminal. + * + * @param command The command to execute. + * @param options Execa options + * + * @returns The result of the command from the terminal. + */ +export async function execGitCommand( + command: string, + options?: Parameters[1] +) { + try { + const { all } = await execaCommand(command, { ...defaultOptions, ...options }); + + if (all === undefined) { + throw new Error(`Unexpected missing git command output for command: '${command}'`); + } + + return all.toString(); + } catch (error) { + const { message } = error; + + if (message.includes('not a git repository')) { + throw new Error(gitNotInitialized({ command })); + } + + if (message.includes('git not found')) { + throw new Error(gitNotInstalled({ command })); + } + + if (message.includes('does not have any commits yet')) { + throw new Error(gitNoCommits({ command })); + } + + throw error; + } +} + +/** + * Execute a Git command in the local terminal and just get the first line. + * + * @param command The command to execute. + * @param options Execa options + * + * @returns The first line of the command from the terminal. + */ +export async function execGitCommandOneLine( + command: string, + options?: Parameters[1] +) { + const process = execaCommand(command, { ...defaultOptions, buffer: false, ...options }); + + return Promise.race([ + // This promise will resolve only if there is an error or it times out + (async () => { + await process; + + throw new Error(`Unexpected missing git command output for command: '${command}'`); + })(), + // We expect this promise to resolve first + new Promise((resolve, reject) => { + if (!process.stdout) { + return reject(new Error('Unexpected missing stdout')); + } + + const rl = createInterface(process.stdout); + rl.once('line', (line) => { + rl.close(); + process.kill(); + + resolve(line); + }); + }), + ]); +} + +/** + * Execute a Git command in the local terminal and count the lines in the result + * + * @param command The command to execute. + * @param options Execa options + * + * @returns The number of lines the command returned + */ +export async function execGitCommandCountLines( + command: string, + options?: Parameters[1] +) { + const process = execaCommand(command, { ...defaultOptions, buffer: false, ...options }); + if (!process.stdout) { + throw new Error('Unexpected missing stdout'); + } + + let lineCount = 0; + const rl = createInterface(process.stdout); + rl.on('line', () => { + lineCount += 1; + }); + + // If the process errors, this will throw + await process; + + return lineCount; +} diff --git a/node-src/git/getParentCommits.ts b/node-src/git/getParentCommits.ts index e4a5f9b65..d40b4351a 100644 --- a/node-src/git/getParentCommits.ts +++ b/node-src/git/getParentCommits.ts @@ -2,7 +2,8 @@ import gql from 'fake-tag'; import { localBuildsSpecifier } from '../lib/localBuildsSpecifier'; import { Context } from '../types'; -import { commitExists, execGitCommand } from './git'; +import { execGitCommand } from './execGit'; +import { commitExists } from './git'; export const FETCH_N_INITIAL_BUILD_COMMITS = 20; diff --git a/node-src/git/git.test.ts b/node-src/git/git.test.ts index 31ea11d8c..116b8dc70 100644 --- a/node-src/git/git.test.ts +++ b/node-src/git/git.test.ts @@ -1,18 +1,24 @@ -import { execaCommand } from 'execa'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as execGit from './execGit'; import { findFilesFromRepositoryRoot, getCommit, + getCommittedFileCount, + getNumberOfComitters, + getRepositoryCreationDate, getSlug, + getStorybookCreationDate, hasPreviousCommit, mergeQueueBranchMatch, NULL_BYTE, } from './git'; -vi.mock('execa'); +vi.mock('./execGit'); -const command = vi.mocked(execaCommand); +const execGitCommand = vi.mocked(execGit.execGitCommand); +const execGitCommandOneLine = vi.mocked(execGit.execGitCommandOneLine); +const execGitCommandCountLines = vi.mocked(execGit.execGitCommandCountLines); afterEach(() => { vi.clearAllMocks(); @@ -20,11 +26,8 @@ afterEach(() => { describe('getCommit', () => { it('parses log output', async () => { - command.mockImplementation( - () => - Promise.resolve({ - all: `19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a ## 1696588814 ## info@ghengeveld.nl ## Gert Hengeveld`, - }) as any + execGitCommand.mockResolvedValue( + `19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a ## 1696588814 ## info@ghengeveld.nl ## Gert Hengeveld` ); expect(await getCommit()).toEqual({ commit: '19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a', @@ -35,16 +38,11 @@ describe('getCommit', () => { }); it('ignores gpg signature information', async () => { - command.mockImplementation( - () => - Promise.resolve({ - all: ` -gpg: Signature made Fri Oct 6 12:40:14 2023 CEST + execGitCommand.mockResolvedValue( + `gpg: Signature made Fri Oct 6 12:40:14 2023 CEST gpg: using RSA key 4AEE18F83AFDEB23 gpg: Can't check signature: No public key -19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a ## 1696588814 ## info@ghengeveld.nl ## Gert Hengeveld - `.trim(), - }) as any +19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a ## 1696588814 ## info@ghengeveld.nl ## Gert Hengeveld`.trim() ); expect(await getCommit()).toEqual({ commit: '19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a', @@ -57,47 +55,35 @@ gpg: Can't check signature: No public key describe('getSlug', () => { it('returns the slug portion of the git url', async () => { - command.mockImplementation( - () => Promise.resolve({ all: 'git@github.com:chromaui/chromatic-cli.git' }) as any - ); + execGitCommand.mockResolvedValue('git@github.com:chromaui/chromatic-cli.git'); expect(await getSlug()).toBe('chromaui/chromatic-cli'); - command.mockImplementation( - () => Promise.resolve({ all: 'https://github.com/chromaui/chromatic-cli' }) as any - ); + execGitCommand.mockResolvedValue('https://github.com/chromaui/chromatic-cli'); expect(await getSlug()).toBe('chromaui/chromatic-cli'); - command.mockImplementation( - () => Promise.resolve({ all: 'https://gitlab.com/foo/bar.baz.git' }) as any - ); + execGitCommand.mockResolvedValue('https://gitlab.com/foo/bar.baz.git'); expect(await getSlug()).toBe('foo/bar.baz'); }); }); describe('hasPreviousCommit', () => { it('returns true if a commit is found', async () => { - command.mockImplementation( - () => Promise.resolve({ all: `19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a` }) as any - ); + execGitCommand.mockResolvedValue(`19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a`); expect(await hasPreviousCommit()).toEqual(true); }); it('returns false if no commit is found', async () => { - command.mockImplementation(() => Promise.resolve({ all: `` }) as any); + execGitCommand.mockResolvedValue(``); expect(await hasPreviousCommit()).toEqual(false); }); it('ignores gpg signature information', async () => { - command.mockImplementation( - () => - Promise.resolve({ - all: ` + execGitCommand.mockResolvedValue( + ` gpg: Signature made Fri Oct 6 12:40:14 2023 CEST gpg: using RSA key 4AEE18F83AFDEB23 gpg: Can't check signature: No public key -19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a - `.trim(), - }) as any +19b6c9c5b3d34d9fc55627fcaf8a85bd5d5e5b2a`.trim() ); expect(await hasPreviousCommit()).toEqual(true); }); @@ -120,28 +106,68 @@ describe('findFilesFromRepositoryRoot', () => { const filesFound = ['package.json', 'another/package/package.json']; // first call from getRepositoryRoot() - command.mockImplementationOnce( - () => - Promise.resolve({ - all: '/root', - }) as any - ); - - command.mockImplementationOnce( - () => - Promise.resolve({ - all: filesFound.join(NULL_BYTE), - }) as any - ); + execGitCommand.mockResolvedValueOnce('/root'); + execGitCommand.mockResolvedValueOnce(filesFound.join(NULL_BYTE)); const results = await findFilesFromRepositoryRoot('package.json', '**/package.json'); - expect(command).toBeCalledTimes(2); - expect(command).toHaveBeenNthCalledWith( + expect(execGitCommand).toBeCalledTimes(2); + expect(execGitCommand).toHaveBeenNthCalledWith( 2, - 'git ls-files --full-name -z "/root/package.json" "/root/**/package.json"', - expect.any(Object) + 'git ls-files --full-name -z "/root/package.json" "/root/**/package.json"' ); expect(results).toEqual(filesFound); }); }); + +describe('getRepositoryCreationDate', () => { + it('parses the date successfully', async () => { + execGitCommandOneLine.mockResolvedValue(`2017-05-17 10:00:35 -0700`); + expect(await getRepositoryCreationDate()).toEqual(new Date('2017-05-17T17:00:35.000Z')); + }); +}); + +describe('getStorybookCreationDate', () => { + it('passes the config dir to the git command', async () => { + await getStorybookCreationDate({ options: { storybookConfigDir: 'special-config-dir' } }); + expect(execGitCommandOneLine).toHaveBeenCalledWith( + expect.stringMatching(/special-config-dir/), + expect.anything() + ); + }); + + it('defaults the config dir to the git command', async () => { + await getStorybookCreationDate({ options: {} }); + expect(execGitCommandOneLine).toHaveBeenCalledWith( + expect.stringMatching(/.storybook/), + expect.anything() + ); + }); + + it('parses the date successfully', async () => { + execGitCommandOneLine.mockResolvedValue(`2017-05-17 10:00:35 -0700`); + expect( + await getStorybookCreationDate({ options: { storybookConfigDir: '.storybook' } }) + ).toEqual(new Date('2017-05-17T17:00:35.000Z')); + }); +}); + +describe('getNumberOfComitters', () => { + it('parses the count successfully', async () => { + execGitCommandCountLines.mockResolvedValue(17); + expect(await getNumberOfComitters()).toEqual(17); + }); +}); + +describe('getCommittedFileCount', () => { + it('constructs the correct command', async () => { + await getCommittedFileCount(['page', 'screen'], ['js', 'ts']); + expect(execGitCommandCountLines).toHaveBeenCalledWith( + 'git ls-files -- "*page*.js" "*page*.ts" "*Page*.js" "*Page*.ts" "*screen*.js" "*screen*.ts" "*Screen*.js" "*Screen*.ts"' + ); + }); + it('parses the count successfully', async () => { + execGitCommandCountLines.mockResolvedValue(17); + expect(await getCommittedFileCount(['page', 'screen'], ['js', 'ts'])).toEqual(17); + }); +}); diff --git a/node-src/git/git.ts b/node-src/git/git.ts index 5621f1c3f..f3676d091 100644 --- a/node-src/git/git.ts +++ b/node-src/git/git.ts @@ -1,51 +1,13 @@ -import { execaCommand } from 'execa'; import { EOL } from 'os'; import pLimit from 'p-limit'; import { file as temporaryFile } from 'tmp-promise'; import { Context } from '../types'; -import gitNoCommits from '../ui/messages/errors/gitNoCommits'; -import gitNotInitialized from '../ui/messages/errors/gitNotInitialized'; -import gitNotInstalled from '../ui/messages/errors/gitNotInstalled'; +import { execGitCommand, execGitCommandCountLines, execGitCommandOneLine } from './execGit'; const newline = /\r\n|\r|\n/; // Git may return \n even on Windows, so we can't use EOL export const NULL_BYTE = '\0'; // Separator used when running `git ls-files` with `-z` -/** - * Execute a Git command in the local terminal. - * - * @param command The command to execute. - * - * @returns The result of the command from the terminal. - */ -export async function execGitCommand(command: string) { - try { - const { all } = await execaCommand(command, { - env: { LANG: 'C', LC_ALL: 'C' }, // make sure we're speaking English - timeout: 20_000, // 20 seconds - all: true, // interleave stdout and stderr - shell: true, // we'll deal with escaping ourselves (for now) - }); - return all; - } catch (error) { - const { message } = error; - - if (message.includes('not a git repository')) { - throw new Error(gitNotInitialized({ command })); - } - - if (message.includes('git not found')) { - throw new Error(gitNotInstalled({ command })); - } - - if (message.includes('does not have any commits yet')) { - throw new Error(gitNoCommits({ command })); - } - - throw error; - } -} - /** * Get the version of Git from the host. * @@ -422,3 +384,86 @@ export async function mergeQueueBranchMatch(branch: string) { return match ? Number(match[1]) : undefined; } + +/** + * Determine the date the repository was created + * + * @returns Date The date the repository was created + */ +export async function getRepositoryCreationDate() { + try { + const dateString = await execGitCommandOneLine(`git log --reverse --format=%cd --date=iso`, { + timeout: 5000, + }); + + return new Date(dateString); + } catch { + return undefined; + } +} + +/** + * Determine the date the storybook was added to the repository + * + * @param ctx Context The context set when executing the CLI. + * @param ctx.options Object standard context options + * @param ctx.options.storybookConfigDir Configured Storybook config dir, if set + * + * @returns Date The date the storybook was added + */ +export async function getStorybookCreationDate(ctx: { + options: { + storybookConfigDir?: Context['options']['storybookConfigDir']; + }; +}) { + try { + const configDirectory = ctx.options.storybookConfigDir ?? '.storybook'; + const dateString = await execGitCommandOneLine( + `git log --follow --reverse --format=%cd --date=iso -- ${configDirectory}`, + { timeout: 5000 } + ); + return new Date(dateString); + } catch { + return undefined; + } +} + +/** + * Determine the number of committers in the last 6 months + * + * @returns number The number of committers + */ +export async function getNumberOfComitters() { + try { + return execGitCommandCountLines(`git shortlog -sn --all --since="6 months ago"`, { + timeout: 5000, + }); + } catch { + return undefined; + } +} + +/** + * Find the number of files in the git index that include a name with the given prefixes. + * + * @param nameMatches The names to match - will be matched with upper and lowercase first letter + * @param extensions The filetypes to match + * + * @returns The number of files matching the above + */ +export async function getCommittedFileCount(nameMatches: string[], extensions: string[]) { + try { + const bothCasesNameMatches = nameMatches.flatMap((match) => [ + match, + [match[0].toUpperCase(), ...match.slice(1)].join(''), + ]); + + const globs = bothCasesNameMatches.flatMap((match) => + extensions.map((extension) => `"*${match}*.${extension}"`) + ); + + return execGitCommandCountLines(`git ls-files -- ${globs.join(' ')}`); + } catch { + return undefined; + } +} diff --git a/node-src/index.test.ts b/node-src/index.test.ts index 1e2f4f2eb..6828507aa 100644 --- a/node-src/index.test.ts +++ b/node-src/index.test.ts @@ -314,6 +314,10 @@ vi.mock('./git/git', () => ({ getUncommittedHash: () => Promise.resolve('abc123'), getUserEmail: () => Promise.resolve('test@test.com'), mergeQueueBranchMatch: () => Promise.resolve(undefined), + getRepositoryCreationDate: () => Promise.resolve(new Date('2024-11-01')), + getStorybookCreationDate: () => Promise.resolve(new Date('2025-11-01')), + getNumberOfComitters: () => Promise.resolve(17), + getCommittedFileCount: () => Promise.resolve(100), })); vi.mock('./git/getParentCommits', () => ({ @@ -325,6 +329,8 @@ const getSlug = vi.mocked(git.getSlug); vi.mock('./lib/emailHash'); +vi.mock('./lib/getHasRouter'); + vi.mock('./lib/getFileHashes', () => ({ getFileHashes: (files: string[]) => Promise.resolve(Object.fromEntries(files.map((f) => [f, 'hash']))), diff --git a/node-src/lib/findChangedPackageFiles.test.ts b/node-src/lib/findChangedPackageFiles.test.ts index c12e89dbb..2f5810991 100644 --- a/node-src/lib/findChangedPackageFiles.test.ts +++ b/node-src/lib/findChangedPackageFiles.test.ts @@ -1,15 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as git from '../git/git'; +import * as execGit from '../git/execGit'; import { arePackageDependenciesEqual, clearFileCache, findChangedPackageFiles, } from './findChangedPackageFiles'; -vi.mock('../git/git'); +vi.mock('../git/execGit'); -const execGitCommand = vi.mocked(git.execGitCommand); +const execGitCommand = vi.mocked(execGit.execGitCommand); const mockFileContents = (packagesCommitsByFile) => { execGitCommand.mockImplementation(async (input) => { diff --git a/node-src/lib/findChangedPackageFiles.ts b/node-src/lib/findChangedPackageFiles.ts index 62053de23..7d0f46311 100644 --- a/node-src/lib/findChangedPackageFiles.ts +++ b/node-src/lib/findChangedPackageFiles.ts @@ -1,4 +1,4 @@ -import { execGitCommand } from '../git/git'; +import { execGitCommand } from '../git/execGit'; import { isPackageMetadataFile } from './utils'; // TODO: refactor this function diff --git a/node-src/lib/getDependentStoryFiles.test.ts b/node-src/lib/getDependentStoryFiles.test.ts index 2b3d73a24..4b43e9585 100644 --- a/node-src/lib/getDependentStoryFiles.test.ts +++ b/node-src/lib/getDependentStoryFiles.test.ts @@ -2,26 +2,30 @@ import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as git from '../git/git'; +import { Context } from '../types'; import { getDependentStoryFiles, normalizePath } from './getDependentStoryFiles'; -vi.mock('../git/git'); - const CSF_GLOB = String.raw`./src sync ^\.\/(?:(?!\.)(?=.)[^/]*?\.stories\.js)$`; const VITE_ENTRY = '/virtual:/@storybook/builder-vite/storybook-stories.js'; const statsPath = 'preview-stats.json'; const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; -const getContext: any = ({ - configDir, - staticDir, - ...options -}: { configDir?: string; staticDir?: string } = {}) => ({ +const getContext: any = ( + { + configDir, + staticDir, + ...options + }: { configDir?: string; staticDir?: string } & Context['options'] = {} as any +) => ({ log, - options: { storybookBaseDir: '.', ...options }, + options, turboSnap: {}, - storybook: { configDir, staticDir }, - git: {}, + storybook: { + baseDir: options.storybookBaseDir ?? '', + configDir, + staticDir, + }, + git: { rootPath: '/path/to/project' }, }); afterEach(() => { @@ -31,10 +35,6 @@ afterEach(() => { log.debug.mockReset(); }); -const getRepositoryRoot = vi.mocked(git.getRepositoryRoot); - -getRepositoryRoot.mockResolvedValue('/path/to/project'); - describe('getDependentStoryFiles', () => { it('detects direct changes to CSF files', async () => { const changedFiles = ['src/foo.stories.js']; @@ -334,7 +334,6 @@ describe('getDependentStoryFiles', () => { }); it('supports absolute module paths', async () => { - getRepositoryRoot.mockResolvedValueOnce('/path/to/project'); const absoluteCsfGlob = `/path/to/project/${CSF_GLOB.slice(2)}`; const changedFiles = ['src/foo.js']; const modules = [ @@ -363,8 +362,6 @@ describe('getDependentStoryFiles', () => { }); it('supports absolute module paths with deviating working dir', async () => { - getRepositoryRoot.mockResolvedValueOnce('/path/to/project'); - const absoluteCsfGlob = `/path/to/project/packages/webapp/${CSF_GLOB.slice(2)}`; const changedFiles = ['packages/webapp/src/foo.js']; const modules = [ diff --git a/node-src/lib/getDependentStoryFiles.ts b/node-src/lib/getDependentStoryFiles.ts index d0940ca78..5c85aa0ff 100644 --- a/node-src/lib/getDependentStoryFiles.ts +++ b/node-src/lib/getDependentStoryFiles.ts @@ -1,10 +1,10 @@ import path from 'path'; -import { getRepositoryRoot } from '../git/git'; import { Context, Module, Reason, Stats } from '../types'; import noCSFGlobs from '../ui/messages/errors/noCSFGlobs'; import tracedAffectedFiles from '../ui/messages/info/tracedAffectedFiles'; import bailFile from '../ui/messages/warnings/bailFile'; +import { posix } from './posix'; import { isPackageManifestFile, matchesFile } from './utils'; type FilePath = string; @@ -28,11 +28,6 @@ const isUserModule = (module_: Module | Reason) => (module_ as Module).id !== null && !INTERNALS.some((re) => re.test((module_ as Module).name || (module_ as Reason).moduleName)); -// Replaces Windows-style backslash path separators with POSIX-style forward slashes, because the -// Webpack stats use forward slashes in the `name` and `moduleName` fields. Note `changedFiles` -// already contains forward slashes, because that's what git yields even on Windows. -const posix = (localPath: string) => localPath.split(path.sep).filter(Boolean).join(path.posix.sep); - // For any path in node_modules, return the package name, including scope prefix if any. const getPackageName = (modulePath: string) => { const [, scopedName] = modulePath.match(/\/node_modules\/(@[\w-]+\/[\w-]+)\//) || []; @@ -85,28 +80,24 @@ export async function getDependentStoryFiles( changedFiles: string[], changedDependencies: string[] = [] ) { + const { rootPath } = ctx.git || {}; + if (!rootPath) { + throw new Error('Failed to determine repository root'); + } + const { + baseDir: baseDirectory = '', configDir: configDirectory = '.storybook', staticDir: staticDirectory = [], viewLayer, } = ctx.storybook || {}; const { storybookBuildDir, - storybookBaseDir, // eslint-disable-next-line unicorn/prevent-abbreviations storybookConfigDir = configDirectory, untraced = [], } = ctx.options; - const rootPath = await getRepositoryRoot(); // e.g. `/path/to/project` (always absolute posix) - if (!rootPath) { - throw new Error('Failed to determine repository root'); - } - - const baseDirectory = storybookBaseDir - ? posix(storybookBaseDir) - : path.posix.relative(rootPath, ''); - // Convert a "webpack path" (relative to storybookBaseDir) to a "git path" (relative to repository root) // e.g. `./src/file.js` => `path/to/storybook/src/file.js` const normalize = (posixPath: FilePath): NormalizedName => { diff --git a/node-src/lib/getHasRouter.test.ts b/node-src/lib/getHasRouter.test.ts new file mode 100644 index 000000000..baea9caa7 --- /dev/null +++ b/node-src/lib/getHasRouter.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest'; + +import { getHasRouter } from './getHasRouter'; + +it('returns true if there is a routing package in package.json', async () => { + expect( + getHasRouter({ + dependencies: { + react: '^18', + 'react-dom': '^18', + 'react-router': '^6', + }, + }) + ).toBe(true); +}); + +it('sreturns false if there is a routing package in package.json dependenices', async () => { + expect( + getHasRouter({ + dependencies: { + react: '^18', + 'react-dom': '^18', + }, + devDependencies: { + 'react-router': '^6', + }, + }) + ).toBe(false); +}); diff --git a/node-src/lib/getHasRouter.ts b/node-src/lib/getHasRouter.ts new file mode 100644 index 000000000..f03203a69 --- /dev/null +++ b/node-src/lib/getHasRouter.ts @@ -0,0 +1,38 @@ +import type { Context } from '../types'; + +const routerPackages = new Set([ + 'react-router', + 'react-router-dom', + 'remix', + '@tanstack/react-router', + 'expo-router', + '@reach/router', + 'react-easy-router', + '@remix-run/router', + 'wouter', + 'wouter-preact', + 'preact-router', + 'vue-router', + 'unplugin-vue-router', + '@angular/router', + '@solidjs/router', + + // metaframeworks that imply routing + 'next', + 'react-scripts', + 'gatsby', + 'nuxt', + '@sveltejs/kit', +]); + +/** + * @param packageJson The package JSON of the project (from context) + * + * @returns boolean Does this project use a routing package? + */ +export function getHasRouter(packageJson: Context['packageJson']) { + // NOTE: we just check real dependencies; if it is in dev dependencies, it may just be an example + return Object.keys(packageJson?.dependencies ?? {}).some((depName) => + routerPackages.has(depName) + ); +} diff --git a/node-src/lib/getStorybookBaseDirectory.test.ts b/node-src/lib/getStorybookBaseDirectory.test.ts new file mode 100644 index 000000000..e727ab98e --- /dev/null +++ b/node-src/lib/getStorybookBaseDirectory.test.ts @@ -0,0 +1,70 @@ +import path from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Context } from '../types'; +import { getStorybookBaseDirectory } from './getStorybookBaseDirectory'; + +const mockedCwd = vi.spyOn(process, 'cwd'); +const mockedRelative = vi.spyOn(path, 'relative'); +const mockedJoin = vi.spyOn(path, 'join'); + +// The definition of posix depends on `path.sep` being correct for the system +// (ie.equal to `\\` for windows), however we can't really mock that as it's a constant +vi.mock('./posix', () => ({ + posix: (localPath: string) => localPath.split('\\').filter(Boolean).join('/'), +})); + +it('defaults to the configured value', () => { + expect(getStorybookBaseDirectory({ options: { storybookBaseDir: 'foobar' } } as Context)).toBe( + 'foobar' + ); +}); + +it('calculates the relative path of the cwd to the git root when they are equal', () => { + const rootPath = '/path/to/project'; + mockedCwd.mockReturnValue(rootPath); + + expect(getStorybookBaseDirectory({ git: { rootPath } } as Context)).toBe('.'); +}); + +it('calculates the relative path of the cwd to the git root we are in subdir', () => { + const rootPath = '/path/to/project'; + mockedCwd.mockReturnValue(`${rootPath}/storybook`); + + expect(getStorybookBaseDirectory({ git: { rootPath } } as Context)).toBe('storybook'); +}); + +it('calculates the relative path of the cwd to the git root when we are outside the git root', () => { + const rootPath = '/path/to/project'; + mockedCwd.mockReturnValue(`/path/to/elsewhere`); + + expect(getStorybookBaseDirectory({ git: { rootPath } } as Context)).toBe('../elsewhere'); +}); + +it('falls back the empty string if there is no git root', () => { + const rootPath = '/path/to/project'; + mockedCwd.mockReturnValue(`${rootPath}/storybook`); + + expect(getStorybookBaseDirectory({} as Context)).toBe('.'); +}); + +describe('with windows paths', () => { + beforeEach(() => { + mockedRelative.mockImplementation(path.win32.relative); + mockedJoin.mockImplementation(path.win32.join); + }); + + afterEach(() => { + mockedRelative.mockRestore(); + mockedJoin.mockRestore(); + }); + + it('uses posix paths even if we are windows', () => { + const rootPath = String.raw`C:\path\to\project`; + mockedCwd.mockReturnValue(String.raw`${rootPath}\storybook\subdir`); + + expect(getStorybookBaseDirectory({ git: { rootPath } } as Context)).toBe('storybook/subdir'); + }); +}); diff --git a/node-src/lib/getStorybookBaseDirectory.ts b/node-src/lib/getStorybookBaseDirectory.ts new file mode 100644 index 000000000..9e9ed6d48 --- /dev/null +++ b/node-src/lib/getStorybookBaseDirectory.ts @@ -0,0 +1,29 @@ +import path from 'path'; + +import { Context } from '../types'; +import { posix } from './posix'; + +/** + * Get the storybook base directory, relative to the git root. + * This is where you run SB from, NOT the config dir. + * + * @param ctx Context Regular context + * + * @returns string The base directory + */ +export function getStorybookBaseDirectory(ctx: Context) { + const { storybookBaseDir } = ctx.options || {}; + if (storybookBaseDir) { + return storybookBaseDir; + } + + const { rootPath } = ctx.git || {}; + if (!rootPath) { + return '.'; + } + + // NOTE: + // - path.relative does not have a leading '.', unless it starts with '../' + // - path.join('.', '') === '.' and path.join('.', '../x') = '../x' + return posix(path.join('.', path.relative(rootPath, ''))); +} diff --git a/node-src/lib/posix.ts b/node-src/lib/posix.ts new file mode 100644 index 000000000..48ba80cfa --- /dev/null +++ b/node-src/lib/posix.ts @@ -0,0 +1,7 @@ +import path from 'path'; + +// Replaces Windows-style backslash path separators with POSIX-style forward slashes, because the +// Webpack stats use forward slashes in the `name` and `moduleName` fields. Note `changedFiles` +// already contains forward slashes, because that's what git yields even on Windows. +export const posix = (localPath: string) => + localPath.split(path.sep).filter(Boolean).join(path.posix.sep); diff --git a/node-src/tasks/gitInfo.test.ts b/node-src/tasks/gitInfo.test.ts index ba122dbe7..f307dbaae 100644 --- a/node-src/tasks/gitInfo.test.ts +++ b/node-src/tasks/gitInfo.test.ts @@ -5,6 +5,7 @@ import { getChangedFilesWithReplacement as getChangedFilesWithReplacementUnmocke import * as getCommitInfo from '../git/getCommitAndBranch'; import { getParentCommits as getParentCommitsUnmocked } from '../git/getParentCommits'; import * as git from '../git/git'; +import { getHasRouter as getHasRouterUnmocked } from '../lib/getHasRouter'; import { setGitInfo } from './gitInfo'; vi.mock('../git/getCommitAndBranch'); @@ -12,15 +13,22 @@ vi.mock('../git/git'); vi.mock('../git/getParentCommits'); vi.mock('../git/getBaselineBuilds'); vi.mock('../git/getChangedFilesWithReplacement'); +vi.mock('../lib/getHasRouter'); const getCommitAndBranch = vi.mocked(getCommitInfo.default); const getChangedFilesWithReplacement = vi.mocked(getChangedFilesWithReplacementUnmocked); const getSlug = vi.mocked(git.getSlug); const getVersion = vi.mocked(git.getVersion); const getUserEmail = vi.mocked(git.getUserEmail); +const getRepositoryCreationDate = vi.mocked(git.getRepositoryCreationDate); +const getRepositoryRoot = vi.mocked(git.getRepositoryRoot); +const getStorybookCreationDate = vi.mocked(git.getStorybookCreationDate); +const getNumberOfComitters = vi.mocked(git.getNumberOfComitters); +const getCommittedFileCount = vi.mocked(git.getCommittedFileCount); const getUncommittedHash = vi.mocked(git.getUncommittedHash); const getBaselineBuilds = vi.mocked(getBaselineBuildsUnmocked); const getParentCommits = vi.mocked(getParentCommitsUnmocked); +const getHasRouter = vi.mocked(getHasRouterUnmocked); const log = { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }; @@ -47,6 +55,13 @@ beforeEach(() => { getVersion.mockResolvedValue('Git v1.0.0'); getUserEmail.mockResolvedValue('user@email.com'); getSlug.mockResolvedValue('user/repo'); + getRepositoryCreationDate.mockResolvedValue(new Date('2024-11-01')); + getRepositoryRoot.mockResolvedValue('/path/to/project'); + getStorybookCreationDate.mockResolvedValue(new Date('2025-11-01')); + getNumberOfComitters.mockResolvedValue(17); + getCommittedFileCount.mockResolvedValue(100); + getHasRouter.mockReturnValue(true); + client.runQuery.mockReturnValue({ app: { isOnboarding: false } }); }); @@ -55,6 +70,7 @@ describe('setGitInfo', () => { const ctx = { log, options: {}, client } as any; await setGitInfo(ctx, {} as any); expect(ctx.git).toMatchObject({ + rootPath: '/path/to/project', commit: '123asdf', branch: 'something', parentCommits: ['asd2344'], @@ -164,4 +180,16 @@ describe('setGitInfo', () => { await setGitInfo(ctx, {} as any); expect(ctx.git.branch).toBe('repo'); }); + + it('sets projectMetadata on context', async () => { + const ctx = { log, options: { isLocalBuild: true }, client } as any; + await setGitInfo(ctx, {} as any); + expect(ctx.projectMetadata).toMatchObject({ + hasRouter: true, + creationDate: new Date('2024-11-01'), + storybookCreationDate: new Date('2025-11-01'), + numberOfCommitters: 17, + numberOfAppFiles: 100, + }); + }); }); diff --git a/node-src/tasks/gitInfo.ts b/node-src/tasks/gitInfo.ts index 1a5e411b8..dc87e5310 100644 --- a/node-src/tasks/gitInfo.ts +++ b/node-src/tasks/gitInfo.ts @@ -4,7 +4,18 @@ import { getBaselineBuilds } from '../git/getBaselineBuilds'; import { getChangedFilesWithReplacement } from '../git/getChangedFilesWithReplacement'; import getCommitAndBranch from '../git/getCommitAndBranch'; import { getParentCommits } from '../git/getParentCommits'; -import { getSlug, getUncommittedHash, getUserEmail, getVersion } from '../git/git'; +import { + getCommittedFileCount, + getNumberOfComitters, + getRepositoryCreationDate, + getRepositoryRoot, + getSlug, + getStorybookCreationDate, + getUncommittedHash, + getUserEmail, + getVersion, +} from '../git/git'; +import { getHasRouter } from '../lib/getHasRouter'; import { exitCodes, setExitCode } from '../lib/setExitCode'; import { createTask, transitionTo } from '../lib/tasks'; import { isPackageMetadataFile, matchesFile } from '../lib/utils'; @@ -84,9 +95,18 @@ export const setGitInfo = async (ctx: Context, task: Task) => { ctx.log.warn('Failed to retrieve uncommitted files hash', err); return undefined; }), + rootPath: await getRepositoryRoot(), ...commitAndBranchInfo, }; + ctx.projectMetadata = { + hasRouter: getHasRouter(ctx.packageJson), + creationDate: await getRepositoryCreationDate(), + storybookCreationDate: await getStorybookCreationDate(ctx), + numberOfCommitters: await getNumberOfComitters(), + numberOfAppFiles: await getCommittedFileCount(['page', 'screen'], ['js', 'jsx', 'ts', 'tsx']), + }; + if (isLocalBuild && !ctx.git.gitUserEmail) { throw new Error(gitUserEmailNotFound()); } diff --git a/node-src/tasks/initialize.test.ts b/node-src/tasks/initialize.test.ts index 1f0faeb85..0ebb83c0d 100644 --- a/node-src/tasks/initialize.test.ts +++ b/node-src/tasks/initialize.test.ts @@ -100,7 +100,7 @@ describe('announceBuild', () => { environment: ':environment', git: { version: 'whatever', matchesBranch: () => false, committedAt: 0 }, pkg: { version: '1.0.0' }, - storybook: { version: '2.0.0', viewLayer: 'react', addons: [] }, + storybook: { baseDir: '', version: '2.0.0', viewLayer: 'react', addons: [] }, runtimeMetadata: { nodePlatform: 'darwin', nodeVersion: '18.12.1', @@ -138,6 +138,9 @@ describe('announceBuild', () => { storybookAddons: ctx.storybook.addons, storybookVersion: ctx.storybook.version, storybookViewLayer: ctx.storybook.viewLayer, + projectMetadata: { + storybookBaseDir: '', + }, ...defaultContext.runtimeMetadata, }, }, diff --git a/node-src/tasks/initialize.ts b/node-src/tasks/initialize.ts index 3bf69c80f..aeeede1c8 100644 --- a/node-src/tasks/initialize.ts +++ b/node-src/tasks/initialize.ts @@ -70,7 +70,7 @@ export const setRuntimeMetadata = async (ctx: Context) => { } }; -export const announceBuild = async (ctx: Context) => { +const announceBuildInput = (ctx: Context) => { const { patchBaseRef, patchHeadRef, preserveMissingSpecs, isLocalBuild } = ctx.options; const { version, @@ -82,33 +82,41 @@ export const announceBuild = async (ctx: Context) => { baselineCommits, packageMetadataChanges, gitUserEmail, + rootPath, ...commitInfo } = ctx.git; // omit some fields; const { rebuildForBuildId, turboSnap } = ctx; const autoAcceptChanges = matchesBranch?.(ctx.options.autoAcceptChanges); + return { + autoAcceptChanges, + patchBaseRef, + patchHeadRef, + preserveMissingSpecs, + ...(gitUserEmail && { gitUserEmailHash: emailHash(gitUserEmail) }), + ...commitInfo, + committedAt: new Date(committedAt), + ciVariables: ctx.environment, + isLocalBuild, + needsBaselines: !!turboSnap && !turboSnap.bailReason, + packageVersion: ctx.pkg.version, + ...ctx.runtimeMetadata, + rebuildForBuildId, + storybookAddons: ctx.storybook.addons, + storybookVersion: ctx.storybook.version, + storybookViewLayer: ctx.storybook.viewLayer, + projectMetadata: { + ...ctx.projectMetadata, + storybookBaseDir: ctx.storybook?.baseDir, + }, + }; +}; + +export const announceBuild = async (ctx: Context) => { + const input = announceBuildInput(ctx); const { announceBuild: announcedBuild } = await ctx.client.runQuery( AnnounceBuildMutation, - { - input: { - autoAcceptChanges, - patchBaseRef, - patchHeadRef, - preserveMissingSpecs, - ...(gitUserEmail && { gitUserEmailHash: emailHash(gitUserEmail) }), - ...commitInfo, - committedAt: new Date(committedAt), - ciVariables: ctx.environment, - isLocalBuild, - needsBaselines: !!turboSnap && !turboSnap.bailReason, - packageVersion: ctx.pkg.version, - ...ctx.runtimeMetadata, - rebuildForBuildId, - storybookAddons: ctx.storybook.addons, - storybookVersion: ctx.storybook.version, - storybookViewLayer: ctx.storybook.viewLayer, - }, - }, + { input }, { retries: 3 } ); @@ -117,7 +125,7 @@ export const announceBuild = async (ctx: Context) => { ctx.announcedBuild = announcedBuild; ctx.isOnboarding = - announcedBuild.number === 1 || (announcedBuild.autoAcceptChanges && !autoAcceptChanges); + announcedBuild.number === 1 || (announcedBuild.autoAcceptChanges && !input.autoAcceptChanges); if (ctx.turboSnap && announcedBuild.app.turboSnapAvailability === 'UNAVAILABLE') { ctx.turboSnap.unavailable = true; diff --git a/node-src/tasks/storybookInfo.test.ts b/node-src/tasks/storybookInfo.test.ts index b1bc59a14..70b7715b3 100644 --- a/node-src/tasks/storybookInfo.test.ts +++ b/node-src/tasks/storybookInfo.test.ts @@ -1,19 +1,26 @@ import { describe, expect, it, vi } from 'vitest'; +import { getStorybookBaseDirectory } from '../lib/getStorybookBaseDirectory'; import storybookInfo from '../lib/getStorybookInfo'; import { setStorybookInfo } from './storybookInfo'; vi.mock('../lib/getStorybookInfo'); +vi.mock('../lib/getStorybookBaseDirectory'); const getStorybookInfo = vi.mocked(storybookInfo); +const mockedGetStorybookBaseDirectory = vi.mocked(getStorybookBaseDirectory); describe('storybookInfo', () => { it('retrieves Storybook metadata and sets it on context', async () => { const storybook = { version: '1.0.0', viewLayer: 'react', addons: [] }; getStorybookInfo.mockResolvedValue(storybook); + mockedGetStorybookBaseDirectory.mockReturnValue(''); - const ctx = {} as any; + const ctx = { packageJson: {}, git: { rootDir: process.cwd() } } as any; await setStorybookInfo(ctx); - expect(ctx.storybook).toEqual(storybook); + expect(ctx.storybook).toEqual({ + ...storybook, + baseDir: '', + }); }); }); diff --git a/node-src/tasks/storybookInfo.ts b/node-src/tasks/storybookInfo.ts index 74552c994..ad7775935 100644 --- a/node-src/tasks/storybookInfo.ts +++ b/node-src/tasks/storybookInfo.ts @@ -1,12 +1,16 @@ import * as Sentry from '@sentry/node'; +import { getStorybookBaseDirectory } from '../lib/getStorybookBaseDirectory'; import getStorybookInfo from '../lib/getStorybookInfo'; import { createTask, transitionTo } from '../lib/tasks'; import { Context } from '../types'; import { initial, pending, success } from '../ui/tasks/storybookInfo'; export const setStorybookInfo = async (ctx: Context) => { - ctx.storybook = (await getStorybookInfo(ctx)) as Context['storybook']; + ctx.storybook = { + ...((await getStorybookInfo(ctx)) as Context['storybook']), + baseDir: getStorybookBaseDirectory(ctx), + }; if (ctx.storybook) { if (ctx.storybook.version) { diff --git a/node-src/types.ts b/node-src/types.ts index 1e39a0f1d..53df87889 100644 --- a/node-src/types.ts +++ b/node-src/types.ts @@ -214,6 +214,8 @@ export interface Context { git: { version?: string; + /** The absolute location on disk of the git project */ + rootPath?: string; /** The current user's email as pre git config */ gitUserEmail?: string; branch: string; @@ -235,6 +237,7 @@ export interface Context { }; storybook: { version: string; + baseDir?: string; configDir: string; staticDir: string[]; viewLayer: string; @@ -250,6 +253,13 @@ export interface Context { }; mainConfigFilePath?: string; }; + projectMetadata: { + hasRouter?: boolean; + creationDate?: Date; + storybookCreationDate?: Date; + numberOfCommitters?: number; + numberOfAppFiles?: number; + }; storybookUrl?: string; announcedBuild: { id: string;