From 87267347f33c54644ad9b50eda20bd3c97deb064 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 24 Jan 2024 00:31:05 -0600 Subject: [PATCH] feat: Add basic validation for entrypoint options (#368) --- src/cli.ts | 7 +- src/core/utils/__tests__/validation.test.ts | 100 ++++++++++++++++++++ src/core/utils/building/internal-build.ts | 52 +++++++++- src/core/utils/validation.ts | 86 +++++++++++++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 src/core/utils/__tests__/validation.test.ts create mode 100644 src/core/utils/validation.ts diff --git a/src/cli.ts b/src/cli.ts index d62a4495..6dbd464d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { build, clean, createServer, initialize, prepare, zip } from '~/core'; import consola, { LogLevels } from 'consola'; import { printHeader } from '~/core/utils/log'; import { formatDuration } from '~/core/utils/time'; +import { ValidationError } from './core/utils/validation'; // TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277 process.env.VITE_CJS_IGNORE_WARNING = 'true'; @@ -174,7 +175,11 @@ function wrapAction( consola.fail( `Command failed after ${formatDuration(Date.now() - startTime)}`, ); - consola.error(err); + if (err instanceof ValidationError) { + // Don't log these errors, they've already been logged + } else { + consola.error(err); + } process.exit(1); } }; diff --git a/src/core/utils/__tests__/validation.test.ts b/src/core/utils/__tests__/validation.test.ts new file mode 100644 index 00000000..9ca603fc --- /dev/null +++ b/src/core/utils/__tests__/validation.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { + fakeArray, + fakeContentScriptEntrypoint, + fakeEntrypoint, + fakeGenericEntrypoint, +} from '../testing/fake-objects'; +import { validateEntrypoints } from '../validation'; + +describe('Validation Utils', () => { + describe('validateEntrypoints', () => { + it('should return no errors when there are no errors', () => { + const entrypoints = fakeArray(fakeEntrypoint); + const expected = { + errors: [], + errorCount: 0, + warningCount: 0, + }; + + const actual = validateEntrypoints(entrypoints); + + expect(actual).toEqual(expected); + }); + + it('should return an error when exclude is not an array', () => { + const entrypoint = fakeGenericEntrypoint({ + options: { + // @ts-expect-error + exclude: 0, + }, + }); + const expected = { + errors: [ + { + type: 'error', + message: '`exclude` must be an array of browser names', + value: 0, + entrypoint, + }, + ], + errorCount: 1, + warningCount: 0, + }; + + const actual = validateEntrypoints([entrypoint]); + + expect(actual).toEqual(expected); + }); + + it('should return an error when include is not an array', () => { + const entrypoint = fakeGenericEntrypoint({ + options: { + // @ts-expect-error + include: 0, + }, + }); + const expected = { + errors: [ + { + type: 'error', + message: '`include` must be an array of browser names', + value: 0, + entrypoint, + }, + ], + errorCount: 1, + warningCount: 0, + }; + + const actual = validateEntrypoints([entrypoint]); + + expect(actual).toEqual(expected); + }); + + it("should return an error when content scripts don't have a matches", () => { + const entrypoint = fakeContentScriptEntrypoint({ + options: { + // @ts-expect-error + matches: null, + }, + }); + const expected = { + errors: [ + { + type: 'error', + message: '`matches` is required', + value: null, + entrypoint, + }, + ], + errorCount: 1, + warningCount: 0, + }; + + const actual = validateEntrypoints([entrypoint]); + + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/src/core/utils/building/internal-build.ts b/src/core/utils/building/internal-build.ts index 81903073..7ad34aa9 100644 --- a/src/core/utils/building/internal-build.ts +++ b/src/core/utils/building/internal-build.ts @@ -1,5 +1,5 @@ import { findEntrypoints } from './find-entrypoints'; -import { InternalConfig, BuildOutput } from '~/types'; +import { InternalConfig, BuildOutput, Entrypoint } from '~/types'; import pc from 'picocolors'; import fs from 'fs-extra'; import { groupEntrypoints } from './group-entrypoints'; @@ -9,7 +9,14 @@ import glob from 'fast-glob'; import { unnormalizePath } from '~/core/utils/paths'; import { rebuild } from './rebuild'; import managePath from 'manage-path'; -import { resolve } from 'node:path'; +import { resolve, relative } from 'node:path'; +import { + ValidationError, + ValidationResult, + ValidationResults, + validateEntrypoints, +} from '../validation'; +import consola from 'consola'; /** * Builds the extension based on an internal config. No more config discovery is performed, the @@ -39,6 +46,17 @@ export async function internalBuild( const entrypoints = await findEntrypoints(config); config.logger.debug('Detected entrypoints:', entrypoints); + + const validationResults = validateEntrypoints(entrypoints); + if (validationResults.errorCount + validationResults.warningCount > 0) { + printValidationResults(config, validationResults); + } + if (validationResults.errorCount > 0) { + throw new ValidationError(`Entrypoint validation failed`, { + cause: validationResults, + }); + } + const groups = groupEntrypoints(entrypoints); const { output, warnings } = await rebuild( config, @@ -88,3 +106,33 @@ async function combineAnalysisStats(config: InternalConfig): Promise { { cwd: config.root, stdio: 'inherit' }, ); } + +function printValidationResults( + config: InternalConfig, + { errorCount, errors, warningCount }: ValidationResults, +) { + (errorCount > 0 ? config.logger.error : config.logger.warn)( + `Entrypoint validation failed: ${errorCount} error${ + errorCount === 1 ? '' : 's' + }, ${warningCount} warning${warningCount === 1 ? '' : 's'}`, + ); + + const cwd = process.cwd(); + const entrypointErrors = errors.reduce((map, error) => { + const entryErrors = map.get(error.entrypoint) ?? []; + entryErrors.push(error); + map.set(error.entrypoint, entryErrors); + return map; + }, new Map()); + + Array.from(entrypointErrors.entries()).forEach(([entrypoint, errors]) => { + consola.log(relative(cwd, entrypoint.inputPath)); + console.log(); + errors.forEach((err) => { + const type = err.type === 'error' ? pc.red('ERROR') : pc.yellow('WARN'); + const recieved = pc.dim(`(recieved: ${JSON.stringify(err.value)})`); + consola.log(` - ${type} ${err.message} ${recieved}`); + }); + console.log(); + }); +} diff --git a/src/core/utils/validation.ts b/src/core/utils/validation.ts new file mode 100644 index 00000000..8867c710 --- /dev/null +++ b/src/core/utils/validation.ts @@ -0,0 +1,86 @@ +import { ContentScriptEntrypoint, Entrypoint } from '~/types'; + +export function validateEntrypoints( + entrypoints: Entrypoint[], +): ValidationResults { + const errors = entrypoints.flatMap((entrypoint) => { + switch (entrypoint.type) { + case 'content-script': + return validateContentScriptEntrypoint(entrypoint); + default: + return validateBaseEntrypoint(entrypoint); + } + }); + + let errorCount = 0; + let warningCount = 0; + for (const err of errors) { + if (err.type === 'warning') warningCount++; + else errorCount++; + } + + return { + errors, + errorCount, + warningCount, + }; +} + +function validateContentScriptEntrypoint( + definition: ContentScriptEntrypoint, +): ValidationResult[] { + const errors = validateBaseEntrypoint(definition); + if (definition.options.matches == null) { + errors.push({ + type: 'error', + message: '`matches` is required', + value: definition.options.matches, + entrypoint: definition, + }); + } + return errors; +} + +function validateBaseEntrypoint(definition: Entrypoint): ValidationResult[] { + const errors: ValidationResult[] = []; + + if ( + definition.options.exclude != null && + !Array.isArray(definition.options.exclude) + ) { + errors.push({ + type: 'error', + message: '`exclude` must be an array of browser names', + value: definition.options.exclude, + entrypoint: definition, + }); + } + if ( + definition.options.include != null && + !Array.isArray(definition.options.include) + ) { + errors.push({ + type: 'error', + message: '`include` must be an array of browser names', + value: definition.options.include, + entrypoint: definition, + }); + } + + return errors; +} + +export interface ValidationResult { + type: 'warning' | 'error'; + message: string; + entrypoint: Entrypoint; + value: any; +} + +export interface ValidationResults { + errors: ValidationResult[]; + errorCount: number; + warningCount: number; +} + +export class ValidationError extends Error {}