diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..6742f26 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import runPackwatch from '.' + +const isUpdatingManifest = process.argv.includes('--update-manifest') +const processExit = runPackwatch({ isUpdatingManifest }) +process.exit(processExit) diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index d82958a..0000000 --- a/src/helpers.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { spawnSync } from 'child_process' -import { readFileSync, writeFileSync } from 'fs' - -const PACKAGE_SIZE_PATT = /package size:\s+([0-9]+\.?[0-9]*\s+[A-Za-z]+)/g -const UNPACKED_SIZE_PATT = /unpacked size:\s+([0-9]+\.?[0-9]*\s+[A-Za-z]+)/g -const SIZE_SUFFIX_PATT = /([A-Za-z]+)/ -const SIZE_MAGNITUDE_PATT = /([0-9]+\.?[0-9]*)/ - -export const MANIFEST_FILENAME = '.packwatch.json' - -type Report = { - packageSize: string - unpackedSize: string - packageSizeBytes?: number - unpackedSizeBytes?: number -} - -type Digest = { - limit: string - packageSize: string -} - -export function convertSizeToBytes(sizeString: string): number { - const sizeSuffix = SIZE_SUFFIX_PATT.exec(sizeString)[1] - const sizeMagnitude = SIZE_MAGNITUDE_PATT.exec(sizeString)[1] - - let multiplier = 1 - - if (sizeSuffix === 'kB') multiplier = 1000 - else if (sizeSuffix === 'mB') { - multiplier = 1000000 - } - - return multiplier * parseFloat(sizeMagnitude) -} - -export function getCurrentPackageStats(): Report { - const { stderr } = spawnSync('npm', ['pack', '--dry-run'], { - encoding: 'utf-8', - }) - const stderrString = String(stderr) - const packageSize = PACKAGE_SIZE_PATT.exec(stderrString)[1] - const unpackedSize = UNPACKED_SIZE_PATT.exec(stderrString)[1] - - return { - packageSize, - unpackedSize, - packageSizeBytes: convertSizeToBytes(packageSize), - unpackedSizeBytes: convertSizeToBytes(unpackedSize), - } -} - -export function getPreviousPackageStats(): Report | null { - try { - const currentManifest = readFileSync(MANIFEST_FILENAME, { - encoding: 'utf-8', - }) - const parsedManifest = JSON.parse(currentManifest) - return { - ...parsedManifest, - packageSizeBytes: convertSizeToBytes(parsedManifest.packageSize), - unpackedSizeBytes: convertSizeToBytes(parsedManifest.unpackedSize), - limitBytes: convertSizeToBytes(parsedManifest.limit), - } - } catch { - /* No manifest */ - } -} - -export function createOrUpdateManifest({ - previous, - current, - updateLimit = false, -}: { - previous?: Digest - current: Report - updateLimit?: boolean -}) { - const { limit } = previous || {} - const { packageSize, unpackedSize } = current - - const newManifest = { - limit: updateLimit ? packageSize : limit || packageSize, - packageSize: packageSize, - unpackedSize: unpackedSize, - } - - writeFileSync(MANIFEST_FILENAME, JSON.stringify(newManifest)) -} diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..c0a52f0 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,8 @@ +export type Report = { + packageSize: string + unpackedSize?: string + packageSizeBytes?: number + unpackedSizeBytes?: number + limit?: string + limitBytes?: number +} diff --git a/src/index.test.ts b/src/index.test.ts index a178827..5851ced 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,22 +3,10 @@ import { readFileSync } from 'fs' import mockFS from 'mock-fs' -jest.mock('child_process', () => ({ - spawnSync: () => ({ stderr: mockPackOutput }) -})) +jest.mock('child_process') -import { - MANIFEST_FILENAME, - convertSizeToBytes, - createOrUpdateManifest, - getCurrentPackageStats, - getPreviousPackageStats, -} from './helpers' - -const mockPackageSize = '1.1 kB' -const mockUnpackedSize = '9000 kB' - -const mockPackOutput = ` +function getPackOutput({ packageSize, unpackedSize }) { + return ` npm notice npm notice 📦 footprint@0.0.0 npm notice === Tarball Contents === @@ -29,150 +17,246 @@ npm notice === Tarball Details === npm notice name: footprint npm notice version: 0.0.0 npm notice filename: footprint-0.0.0.tgz -npm notice package size: ${mockPackageSize} -npm notice unpacked size: ${mockUnpackedSize} +npm notice package size: ${packageSize} +npm notice unpacked size: ${unpackedSize} npm notice shasum: bdf33d471543cd8126338a82a27b16a9010b8dbd npm notice integrity: sha512-ZZvTg9GVcJw8J[...]bkE0xlqQhlt4Q== npm notice total files: 3 npm notice ` -describe('Helpers', () => { +} + +import runPackwatch from '.' + +function getManifest() { + try { + return JSON.parse(readFileSync('.packwatch.json', { encoding: 'utf8' })) + } catch { + /* No manifest */ + } +} + +function setupMockFS({ + hasPackageJSON, + hasManifest, + manifestLimit, + manifestSize, +}) { + const fs = {} + + if (hasPackageJSON) fs['package.json'] = '{}' + + if (hasManifest) + fs['.packwatch.json'] = JSON.stringify({ + unpackedSize: '0.5 B', + limitBytes: manifestLimit, + limit: `${manifestLimit} B`, + packageSize: `${manifestSize} B`, + packageSizeBytes: manifestSize, + }) + mockFS(fs) +} +describe('Packwatch', () => { + let mockLogger beforeEach(() => { - mockFS.restore() - jest.restoreAllMocks() + mockFS({}) + mockLogger = jest.spyOn(global.console, 'log').mockImplementation() }) + afterEach(jest.restoreAllMocks) + afterAll(mockFS.restore) - describe('Size string conversion', () => { - it.each` - sizeString | expectedValue - ${'1 B'} | ${1} - ${'1.1 B'} | ${1.1} - ${'1 kB'} | ${1000} - ${'1.1kB'} | ${1100} - ${'1 mB'} | ${1000000} - ${'1.1 mB'} | ${1100000} - `( - 'converts $sizeString properly to $expectedValue bytes', - ({ sizeString, expectedValue }) => { - expect(convertSizeToBytes(sizeString)).toEqual(expectedValue) - }, + it('warns the user and errors if run away from package.json', () => { + mockFS({}) + runPackwatch() + + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot( + '"🤔 There is no package.json file here. Are you in the root directory of your project?"', ) }) - describe('Current package statistics', () => { - it('constructs the current package report properly', () => { - const packageSizeBytes = 1100 - const unpackedSizeBytes = 9000000 + describe('without manifest', () => { + beforeEach(() => { + setupMockFS({ hasPackageJSON: true }) + }) - expect(getCurrentPackageStats()).toEqual({ - packageSize: mockPackageSize, - packageSizeBytes, - unpackedSize: mockUnpackedSize, - unpackedSizeBytes, + it.each(['1 B', '1.1 B', '1 kB', '1.1 kB', '1 mB', '1.1 mB'])( + 'generates the initial manifest properly (size = %s)', + mockSize => { + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: mockSize, + unpackedSize: mockSize, + }), + }) + const returnCode = runPackwatch() + const manifest = getManifest() + expect(returnCode).toEqual(1) + expect(manifest).toEqual({ + limit: mockSize, + packageSize: mockSize, + unpackedSize: mockSize, + }) + }, + ) + + it('outputs expected messaging', () => { + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), }) + + runPackwatch() + + expect(mockLogger.mock.calls).toHaveLength(2) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot(` + "📝 No Manifest to compare against! Current package stats written to .packwatch.json! + Package size (1 B) adopted as new limit." + `) + expect(mockLogger.mock.calls[1][0]).toMatchInlineSnapshot( + '"❗ It looks like you ran PackWatch without a manifest. To prevent accidental passes in CI or hooks, packwatch will terminate with an error. If you are running packwatch for the first time in your project, this is expected!"', + ) + }) + + it('outputs expected messaging when not updating the manifest', () => { + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), + }) + + runPackwatch({ isUpdatingManifest: true }) + + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot(` + "📝 No Manifest to compare against! Current package stats written to .packwatch.json! + Package size (1 B) adopted as new limit." + `) }) }) - describe('Previous package statistics', () => { - it('constructs the previous package report properly', () => { - const packageSize = '0.9 kB' - const packageSizeBytes = 900 - const unpackedSize = '90 kB' - const unpackedSizeBytes = 90000 - const limit = '1 kB' - const limitBytes = 1000 - const mockReport = { packageSize, unpackedSize, limit } - mockFS({ [MANIFEST_FILENAME]: JSON.stringify(mockReport) }) - - expect(getPreviousPackageStats()).toEqual({ - packageSize, - packageSizeBytes, - unpackedSize, - unpackedSizeBytes, - limit, - limitBytes, + describe('with manifest', () => { + it('messages when the size is equal to the limit', () => { + setupMockFS({ + hasPackageJSON: true, + hasManifest: true, + manifestLimit: 1, + manifestSize: 1, }) + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), + }) + runPackwatch() + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot( + '"📦 Nothing to report! Your package is the same size as the latest manifest reports! (Limit: 1 B)"', + ) }) - it('returns an empty manifest if it fails to reads the manifest file', () => { - mockFS({ - [MANIFEST_FILENAME]: 'not valid JSON', + it('messages when the size is lower than the limit (no growth)', () => { + setupMockFS({ + hasPackageJSON: true, + hasManifest: true, + manifestLimit: 5, + manifestSize: 1, }) - - expect(getPreviousPackageStats()).toBeUndefined() + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), + }) + runPackwatch() + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot( + '"📦 Nothing to report! Your package is the same size as the latest manifest reports! (Limit: 5 B)"', + ) }) - }) - - describe('Creating or updating the manifest', () => { - const currentStats = { - packageSize: '1 kB', - unpackedSize: '10 kB', - } - - const previousManifest = { - limit: '2 kB', - packageSize: '1.5 kB', - } - it('creates a anifest from the current data if no previous data is provided', () => { - mockFS({}) - - createOrUpdateManifest({ current: currentStats }) - - const writtenManifest = readFileSync(MANIFEST_FILENAME, { - encoding: 'utf-8', + it('messages when the size is lower than the limit (growth)', () => { + setupMockFS({ + hasPackageJSON: true, + hasManifest: true, + manifestLimit: 5, + manifestSize: 2, }) - - expect(JSON.parse(writtenManifest)).toEqual({ - packageSize: currentStats.packageSize, - unpackedSize: currentStats.unpackedSize, - limit: currentStats.packageSize, + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '3 B', + unpackedSize: '2 B', + }), }) + runPackwatch() + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot( + '"📦 👀 Your package grew! 3 B > 2 B (Limit: 5 B)"', + ) + }) + it('messages when the size is lower than the limit (shrinkage)', () => { + setupMockFS({ + hasPackageJSON: true, + hasManifest: true, + manifestLimit: 5, + manifestSize: 2, + }) + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), + }) + runPackwatch() + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot( + '"📦 💯 Your package shrank! 1 B < 2 B (Limit: 5 B)"', + ) + }) + it('messages when the size exceeds the limit', () => { + setupMockFS({ + hasPackageJSON: true, + hasManifest: true, + manifestLimit: 0.5, + manifestSize: 0.5, + }) + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), + }) + runPackwatch() + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot(` + "🔥🔥📦🔥🔥 Your package exceeds the limit set in .packwatch.json! 1 B > 0.5 B + Either update the limit by using the --update-manifest flag or trim down your packed files!" + `) }) - it('updates the previous manifest sizes if previous data exists', () => { - mockFS({ - [MANIFEST_FILENAME]: JSON.stringify(previousManifest), + it('messages when updating the manifest', () => { + setupMockFS({ + hasPackageJSON: true, + hasManifest: true, + manifestLimit: 0.5, + manifestSize: 0.5, }) - - createOrUpdateManifest({ - current: currentStats, - previous: previousManifest, - updateLimit: false, - }) - - const writtenManifest = readFileSync(MANIFEST_FILENAME, { - encoding: 'utf-8', - }) - - expect(JSON.parse(writtenManifest)).toEqual({ - packageSize: currentStats.packageSize, - unpackedSize: currentStats.unpackedSize, - limit: previousManifest.limit, - }) - }) - - it('updates the previous manifest sizes and limit if previous data exists and updateLimit is set', () => { - mockFS({ - [MANIFEST_FILENAME]: JSON.stringify(previousManifest), - }) - - createOrUpdateManifest({ - current: currentStats, - previous: previousManifest, - updateLimit: true, - }) - - const writtenManifest = readFileSync(MANIFEST_FILENAME, { - encoding: 'utf-8', - }) - - expect(JSON.parse(writtenManifest)).toEqual({ - packageSize: currentStats.packageSize, - unpackedSize: currentStats.unpackedSize, - limit: currentStats.packageSize, + jest.spyOn(childProcess, 'spawnSync').mockReturnValue({ + stderr: getPackOutput({ + packageSize: '1 B', + unpackedSize: '2 B', + }), }) + runPackwatch({ isUpdatingManifest: true }) + expect(mockLogger.mock.calls).toHaveLength(1) + expect(mockLogger.mock.calls[0][0]).toMatchInlineSnapshot( + '"📝 Updated the manifest! Package size: 1 B, Limit: 1 B"', + ) }) }) }) diff --git a/src/index.ts b/src/index.ts index 4d05c07..fb7d0ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,106 +1,177 @@ -#!/usr/bin/env node +import { spawnSync } from 'child_process' +import { existsSync, readFileSync, writeFileSync } from 'fs' -const { existsSync } = require('fs') +import { Report } from './index.d' -const { - MANIFEST_FILENAME, - getCurrentPackageStats, - getPreviousPackageStats, - createOrUpdateManifest, -} = require('./helpers') +const PACKAGE_SIZE_PATT = /package size:\s*([0-9]+\.?[0-9]*\s+[A-Za-z]{1,2})/ +const UNPACKED_SIZE_PATT = /unpacked size:\s*([0-9]+\.?[0-9]*\s+[A-Za-z]{1,2})/ +const SIZE_SUFFIX_PATT = /([A-Za-z]+)/ +const SIZE_MAGNITUDE_PATT = /([0-9]+\.?[0-9]*)/ -if (!existsSync('package.json')) { - console.log( - '🤔 There is no package.json file here. Are you in the root directory of your project?', - ) - process.exit(1) +const MANIFEST_FILENAME = '.packwatch.json' + +function convertSizeToBytes(sizeString: string): number { + const sizeSuffix = SIZE_SUFFIX_PATT.exec(sizeString)[1] + const sizeMagnitude = SIZE_MAGNITUDE_PATT.exec(sizeString)[1] + + let multiplier = 1 + + if (sizeSuffix === 'kB') multiplier = 1000 + else if (sizeSuffix === 'mB') { + multiplier = 1000000 + } + + return multiplier * parseFloat(sizeMagnitude) } -const isUpdatingManifest = process.argv.includes('--update-manifest') +function getCurrentPackageStats(): Report { + const { stderr } = spawnSync('npm', ['pack', '--dry-run'], { + encoding: 'utf-8', + }) + const stderrString = String(stderr) + const packageSize = PACKAGE_SIZE_PATT.exec(stderrString)[1] + const unpackedSize = UNPACKED_SIZE_PATT.exec(stderrString)[1] -const currentStats = getCurrentPackageStats() + return { + packageSize, + unpackedSize, + packageSizeBytes: convertSizeToBytes(packageSize), + unpackedSizeBytes: convertSizeToBytes(unpackedSize), + } +} -/* - * If there is no manifest file yet, we can use the current package stats as - * a base to build one. The current package size becomes the limit. - */ +function getPreviousPackageStats(): Report | null { + try { + const currentManifest = readFileSync(MANIFEST_FILENAME, { + encoding: 'utf-8', + }) + const parsedManifest = JSON.parse(currentManifest) + return { + ...parsedManifest, + packageSizeBytes: convertSizeToBytes(parsedManifest.packageSize), + unpackedSizeBytes: convertSizeToBytes(parsedManifest.unpackedSize), + limitBytes: convertSizeToBytes(parsedManifest.limit), + } + } catch { + /* No manifest */ + } +} -if (!existsSync(MANIFEST_FILENAME)) { - createOrUpdateManifest({ current: currentStats }) - console.log( - `📝 No Manifest to compare against! Current package stats written to ${MANIFEST_FILENAME}!`, - ) - console.log( - `Package size (${currentStats.packageSize}) adopted as new limit.`, - ) +function createOrUpdateManifest({ + previous, + current, + updateLimit = false, +}: { + previous?: Report + current: Report + updateLimit?: boolean +}): void { + const { limit } = previous || {} + const { packageSize, unpackedSize } = current - if (!isUpdatingManifest) { + const newManifest = { + limit: updateLimit ? packageSize : limit || packageSize, + packageSize: packageSize, + unpackedSize: unpackedSize, + } + + writeFileSync(MANIFEST_FILENAME, JSON.stringify(newManifest)) +} +export default function run( + { + manifestFn = MANIFEST_FILENAME, + isUpdatingManifest, + }: { manifestFn?: string; isUpdatingManifest?: boolean } = { + manifestFn: MANIFEST_FILENAME, + isUpdatingManifest: false, + }, +): number { + if (!existsSync('package.json')) { console.log( - '❗ It looks like you ran PackWatch without a manifest. To prevent accidental passes in CI or hooks, packwatch will terminate with an error. If you are running packwatch for the first time in your project, this is expected!', + '🤔 There is no package.json file here. Are you in the root directory of your project?', + ) + return 1 + } + + const currentStats = getCurrentPackageStats() + + /* + * If there is no manifest file yet, we can use the current package stats as + * a base to build one. The current package size becomes the limit. + */ + + if (!existsSync(manifestFn)) { + createOrUpdateManifest({ current: currentStats }) + console.log( + `📝 No Manifest to compare against! Current package stats written to ${MANIFEST_FILENAME}!\nPackage size (${currentStats.packageSize}) adopted as new limit.`, + ) + + if (!isUpdatingManifest) { + console.log( + '❗ It looks like you ran PackWatch without a manifest. To prevent accidental passes in CI or hooks, packwatch will terminate with an error. If you are running packwatch for the first time in your project, this is expected!', + ) + } + // If the update flag wasn't specified, exit with a non-zero code so we + // don't "accidentally" pass CI builds if the manifest didn't exist + return isUpdatingManifest ? 0 : 1 + } + + const previousStats = getPreviousPackageStats() + const { packageSizeBytes, packageSize } = currentStats + const { + packageSize: previousSize, + packageSizeBytes: previousSizeBytes, + limit, + limitBytes, + } = previousStats + const hasExceededLimit = packageSizeBytes > limitBytes + + /* + * If we are updating the manifest, we can write right away and terminate. + */ + + if (isUpdatingManifest) { + createOrUpdateManifest({ + previous: previousStats, + current: currentStats, + updateLimit: true, + }) + console.log( + `📝 Updated the manifest! Package size: ${packageSize}, Limit: ${packageSize}`, + ) + return 0 + } + + /* + * If there is a manifest file and the current package busts its limit + * we signal it and terminate with an error. + */ + + if (hasExceededLimit) { + console.log( + `🔥🔥📦🔥🔥 Your package exceeds the limit set in ${MANIFEST_FILENAME}! ${packageSize} > ${limit}\nEither update the limit by using the --update-manifest flag or trim down your packed files!`, + ) + return 1 + } + + /* + * If there is a manifest file and the limit is not busted, we give + * the user some feedback on how the current package compares with + * the previous one. + */ + + if (packageSizeBytes > previousSizeBytes) { + console.log( + `📦 👀 Your package grew! ${packageSize} > ${previousSize} (Limit: ${limit})`, + ) + } else if (packageSizeBytes < previousSizeBytes) { + console.log( + `📦 💯 Your package shrank! ${packageSize} < ${previousSize} (Limit: ${limit})`, + ) + } else { + console.log( + `📦 Nothing to report! Your package is the same size as the latest manifest reports! (Limit: ${limit})`, ) } - // If the update flag wasn't specified, exit with a non-zero code so we - // don't "accidentally" pass CI builds if the manifest didn't exist - process.exit(isUpdatingManifest ? 0 : 1) -} - -const previousStats = getPreviousPackageStats() -const { packageSizeBytes, packageSize } = currentStats -const { - packageSize: previousSize, - packageSizeBytes: previousSizeBytes, - limit, - limitBytes, -} = previousStats -const hasExceededLimit = packageSizeBytes > limitBytes - -/* - * If we are updating the manifest, we can write right away and terminate. - */ - -if (isUpdatingManifest) { - createOrUpdateManifest({ - previous: previousStats, - current: currentStats, - updateLimit: true, - }) - console.log( - `📝 Updated the manifest! Package size: ${packageSize}, Limit: ${packageSize}`, - ) - process.exit(0) -} - -/* - * If there is a manifest file and the current package busts its limit - * we signal it and terminate with an error. - */ - -if (hasExceededLimit) { - console.log( - `🔥🔥📦🔥🔥 Your package exceeds the limit set in ${MANIFEST_FILENAME}! ${packageSize} > ${limit}`, - ) - console.log( - 'Either update the limit by using the --update-manifest flag or trim down your packed files!', - ) - process.exit(1) -} - -/* - * If there is a manifest file and the limit is not busted, we give - * the user some feedback on how the current package compares with - * the previous one. - */ - -if (packageSizeBytes > previousSizeBytes) { - console.log( - `📦 👀 Your package grew! ${packageSize} > ${previousSize} (Limit: ${limit})`, - ) -} else if (packageSizeBytes < previousSizeBytes) { - console.log( - `📦 💯 Your package shrank! ${packageSize} < ${previousSize} (Limit: ${limit})`, - ) -} else { - console.log( - `📦 Nothing to report! Your package is the same size as the latest manifest reports! (Limit: ${limit})`, - ) + return 0 }