import { MonoRepoKind } from "../common/monoRepo" ; import { Package } from "../common/npmPackage" ; import path from "path" ; import { existsSync , readFileAsync , writeFileAsync , resolveNodeModule } from "../common/utils" ; import * as TscUtils from "./tscUtils" ; import sortPackageJson from "sort-package-json" ; import isEqual from "lodash.isequal" ; import chalk from "chalk" ; import fs from "fs" ; export class FluidPackageCheck { private static fixPackageVersions : { [ key ] : string ; } = { "cross-env" : "^7.0.2" , "jest-junit" : "^10.0.0" , "nyc" : "^15.0.0" , "rimraf" : "^2.6.2" , } ; private static ensureDevDependency ( pkg , fix , pkgName ) { if ( pkg . packageJson . devDependencies && pkg . packageJson . devDependencies [ pkgName ] ) { return ; } const pkgVersion = this . fixPackageVersions [ pkgName ] ; if ( ! pkgVersion ) { fix = false ; } this . logWarn ( pkg , `missing ${ pkgName } dependency` , fix ) ; if ( fix ) { if ( ! pkg . packageJson . devDependencies ) { pkg . packageJson . devDependencies = { } ; } pkg . packageJson . devDependencies [ pkgName ] = pkgVersion ; } return fix ; } public static checkScripts ( pkg , fix ) { const fixed = [ FluidPackageCheck . checkSort ( pkg , fix ) , FluidPackageCheck . checkBuildScripts ( pkg , fix ) , FluidPackageCheck . checkCleanScript ( pkg , fix ) , FluidPackageCheck . checkTestCoverageScripts ( pkg , fix ) , FluidPackageCheck . checkTestScripts ( pkg , fix ) , FluidPackageCheck . checkClientTestScripts ( pkg , fix ) , FluidPackageCheck . checkJestJunitTestEntry ( pkg , fix ) , FluidPackageCheck . checkLintScripts ( pkg , fix ) , ] ; return fixed . some ( ( bool ) => bool ) ; } private static logWarn ( pkg , message , fix ) { console . warn ( `${ pkg . nameColored }: warning: ${ message }${ chalk . greenBright ( fix ? " [FIXED]" : "" ) }` ) ; } /**
     * Verify that all packages with 'test' scripts require the 'mocha-test-setup' package
     * and have --unhandled-rejections=strict flag so to failed test with unhandled rejection
     */ private static checkTestScripts ( pkg , fix ) { let fixed = false ; const testMochaScript = pkg . getScript ( "test:mocha" ) ; const testScriptName = testMochaScript ? "test:mocha" : "test" ; const testScript = testMochaScript ?? pkg . getScript ( testScriptName ) ; if ( testScript && /(ts-)?mocha/ . test ( testScript ) ) { const isClient = pkg . monoRepo ?. kind === MonoRepoKind . Client ; const hasConfig = testScript . includes ( " --config " ) ; if ( isClient ) { const pkgstring = "@fluidframework/mocha-test-setup" ; if ( this . ensureDevDependency ( pkg , fix , pkgstring ) ) { fixed = true ; } if ( ! hasConfig ) { const requireString = `node_modules/${ pkgstring }` ; if ( ! testScript . includes ( requireString ) ) { this . logWarn ( pkg , `no ${ requireString } require in test script` , fix ) ; if ( fix ) { pkg . packageJson . scripts [ testScriptName ] += " -r " + requireString ; fixed = true ; } } } const verboseTestScriptName = `${ testScriptName }:verbose` ; const verboseTestMochaScript = pkg . getScript ( verboseTestScriptName ) ; const expectedVerboseTestMochaScript = `cross-env FLUID_TEST_VERBOSE=1 npm run ${ testScriptName }` ; if ( verboseTestMochaScript !== expectedVerboseTestMochaScript ) { this . logWarn ( pkg , `${ verboseTestScriptName } script not match "${ expectedVerboseTestMochaScript }"` , fix ) ; if ( fix ) { pkg . packageJson . scripts [ verboseTestScriptName ] = expectedVerboseTestMochaScript ; fixed = true ; } } if ( this . ensureDevDependency ( pkg , fix , "cross-env" ) ) { fixed = true ; } } if ( ! hasConfig ) { const unhandledRejectionsSwitch = "--unhandled-rejections=strict" ; if ( ! testScript . includes ( unhandledRejectionsSwitch ) ) { this . logWarn ( pkg , `missing --unhandled-rejection switch in test script` , fix ) ; if ( fix ) { pkg . packageJson . scripts [ testScriptName ] += ` ${ unhandledRejectionsSwitch }` ; fixed = true ; } } } } return fixed ; } private static checkScript ( pkg , name , expected , fix ) { let fixed = false ; const actual = pkg . getScript ( name ) ; if ( expected !== actual ) { this . logWarn ( pkg , `non-conformant script "${ name }"` , fix ) ; this . logWarn ( pkg , `  expect: ${ expected }` , fix ) ; this . logWarn ( pkg , `  actual: ${ actual }` , fix ) ; if ( fix ) { pkg . packageJson . scripts [ name ] = expected ; fixed = true ; } } return fixed ; } private static checkChildrenScripts ( pkg , name , expected , concurrent , fix ) { const expectedScript = expected ? concurrent ? `concurrently ${ expected . map ( ( value ) => `npm:${ value }` ) . join ( " " ) }` : expected . map ( ( value ) => `npm run ${ value }` ) . join ( " && " ) : undefined ; return this . checkScript ( pkg , name , expectedScript , fix ) ; } /**
     * mocha tests in packages/ should be in a "test:mocha" script so they can be run separately from jest tests
     */ public static checkClientTestScripts ( pkg , fix ) { let fixed = false ; const testScript = pkg . getScript ( "test" ) ; const testMochaScript = pkg . getScript ( "test:mocha" ) ; const testJestScript = pkg . getScript ( "test:jest" ) ; const expectedTestScripts = [ ] ; if ( testMochaScript ) { if ( pkg . getScript ( "start:tinylicious:test" ) !== undefined ) { expectedTestScripts . push ( "start-server-and-test start:tinylicious:test 7070 test:mocha" ) ; } else { expectedTestScripts . push ( "npm run test:mocha" ) ; } } if ( testJestScript ) { expectedTestScripts . push ( "npm run test:jest" ) ; } let expectedTestScript = expectedTestScripts . length > 0 ? expectedTestScripts . join ( " && " ) : undefined ; if ( testScript ?. startsWith ( "nyc " ) ) { expectedTestScript = `nyc ${ expectedTestScript }` ; } if ( pkg . monoRepo ?. kind === MonoRepoKind . Client && testScript && /^(ts-)?mocha/ . test ( testScript ) ) { this . logWarn ( pkg , `"mocha" in "test" script instead of "test:mocha" script` , fix ) ; if ( fix ) { if ( ! testMochaScript ) { pkg . packageJson . scripts [ "test:mocha" ] = pkg . packageJson . scripts [ "test" ] ; pkg . packageJson . scripts [ "test" ] = expectedTestScript ; fixed = true ; } else { console . warn ( `${ pkg . nameColored }: couldn't fix: "test" and "test:mocha" scripts both present` ) ; } } } else if ( expectedTestScript && this . checkScript ( pkg , "test" , expectedTestScript , fix ) ) { fixed = true ; } return fixed ; } private static checkJestJunitTestEntry ( pkg , fix ) { let fixed = false ; const pkgstring = "jest-junit" ; const testScript = pkg . getScript ( "test:jest" ) ; if ( testScript ) { if ( this . ensureDevDependency ( pkg , fix , pkgstring ) ) { fixed = true ; } if ( ! pkg . packageJson [ "jest-junit" ] ) { this . logWarn ( pkg , `no jest-junit entry for jest test` , false ) ; } } return fixed ; } private static checkTestCoverageScripts ( pkg , fix ) { let fixed = false ; const testCoverageScript = pkg . getScript ( "test:coverage" ) ; if ( testCoverageScript && testCoverageScript . startsWith ( "nyc" ) ) { if ( this . ensureDevDependency ( pkg , fix , "nyc" ) ) { fixed = true ; } if ( pkg . packageJson . nyc ) { if ( pkg . packageJson . nyc [ "exclude-after-remap" ] !== false ) { this . logWarn ( pkg , `nyc.exclude-after-remap need to be false` , fix ) ; if ( fix ) { pkg . packageJson . nyc [ "exclude-after-remap" ] = false ; fixed = true ; } } } else { this . logWarn ( pkg , `missing nyc configuration` , false ) ; } } return fixed ; } private static checkBuildScripts ( pkg , fix ) { let fixed = false ; const buildScript = pkg . getScript ( "build" ) ; if ( buildScript ) { if ( buildScript . startsWith ( "echo " ) || buildScript === "npm run noop" ) { return ; } const build = [ "build:compile" ] ; const buildCompile = [ ] ; const buildCommonJs = [ ] ; const hasFull = pkg . getScript ( "build:full" ) !== undefined ; const buildFull = pkg . packageJson . private && ! hasFull ? [ ] : [ "build" ] ; const buildFullCompile = pkg . packageJson . private && ! hasFull ? [ ] : [ "build:compile" ] ; const prepack = [ ] ; let concurrentBuildCompile = true ; const buildPrefix = pkg . getScript ( "build:gen" ) ? "npm run build:gen && " : ( pkg . getScript ( "build:genver" ) ? "npm run build:genver && " : "" ) ; const buildSuffix = ( pkg . getScript ( "build:docs" ) && ( pkg . name . startsWith ( "@fluidframework" ) || pkg . getScript ( "build" ) ?. endsWith ( " && npm run build:docs" ) ) ) ? " && npm run build:docs" : "" ; if ( pkg . getScript ( "tsc" ) ) { if ( pkg . getScript ( "build:commonjs" ) ) { buildCommonJs . push ( "tsc" ) ; } else { buildCompile . push ( "tsc" ) ; } } const splitTestBuild = this . splitTestBuild ( pkg , true ) ; if ( pkg . getScript ( "build:test" ) || splitTestBuild ) { if ( pkg . getScript ( "build:commonjs" ) ) { buildCommonJs . push ( "build:test" ) ; } else { buildCompile . push ( "build:test" ) ; concurrentBuildCompile = ! splitTestBuild ; } } if ( pkg . getScript ( "build:realsvctest" ) ) { buildCompile . push ( "build:realsvctest" ) ; } if ( pkg . getScript ( "build:commonjs" ) ) { buildCompile . push ( "build:commonjs" ) ; } if ( pkg . getScript ( "build:es5" ) ) { buildCompile . push ( "build:es5" ) ; } if ( pkg . getScript ( "build:esnext" ) ) { buildCompile . push ( "build:esnext" ) ; } if ( pkg . getScript ( "build:copy" ) ) { buildCompile . push ( "build:copy" ) ; } if ( pkg . getScript ( "lint" ) ) { build . push ( "lint" ) ; } if ( pkg . getScript ( "less" ) ) { buildCompile . push ( "less" ) ; } let implicitWebpack = true ; if ( pkg . getScript ( "build:webpack" ) ) { buildCompile . push ( "build:webpack" ) ; implicitWebpack = false ; } if ( pkg . getScript ( "webpack" ) ) { if ( implicitWebpack ) { buildFull . push ( "webpack" ) ; buildFullCompile . push ( "webpack" ) ; } if ( ! pkg . packageJson . private ) { prepack . push ( "webpack" ) ; } } const check = ( scriptName , parts , concurrently = true , prefix = "" , suffix = "" ) => { const expected = parts . length === 0 ? undefined : prefix + ( parts . length > 1 && concurrently ? `concurrently npm:${ parts . join ( " npm:" ) }` : `npm run ${ parts . join ( " && npm run " ) }` ) + suffix ; if ( this . checkScript ( pkg , scriptName , expected , fix ) ) { fixed = true ; } } ; check ( "build" , build , true , buildPrefix , buildSuffix ) ; if ( buildCompile . length === 0 ) { if ( this . checkScript ( pkg , "build:compile" , "tsc" , fix ) ) { fixed = true ; } } else { check ( "build:compile" , buildCompile , concurrentBuildCompile ) ; } check ( "build:commonjs" , buildCommonJs , false ) ; check ( "build:full" , buildFull ) ; check ( "build:full:compile" , buildFullCompile ) ; if ( ! pkg . packageJson . private ) { check ( "prepack" , prepack ) ; } const testDirs = this . getTestDirs ( pkg ) ; if ( testDirs . length === 1 ) { const expectedBuildTest = `tsc --project ./src/test/${ testDirs [ 0 ] ? testDirs [ 0 ] + "/" : "" }tsconfig.json` ; if ( this . checkScript ( pkg , "build:test" , expectedBuildTest , fix ) ) { fixed = true ; } } else if ( testDirs . length !== 0 ) { check ( "build:test" , testDirs . map ( ( dir ) => { const script = `build:test:${ dir }` ; const expectedBuildTest = `tsc --project ./src/test/${ dir }/tsconfig.json` ; if ( this . checkScript ( pkg , script , expectedBuildTest , fix ) ) { fixed = true ; } return script ; } ) ) ; } } return fixed ; } private static checkCleanScript ( pkg , fix ) { const cleanScript = pkg . getScript ( "clean" ) ; if ( ! cleanScript ) { if ( pkg . getScript ( "build" ) ) { this . logWarn ( pkg , `package has "build" script without "clean" script` , false ) ; } return false ; } if ( cleanScript . startsWith ( "rimraf" ) ) { return this . ensureDevDependency ( pkg , fix , "rimraf" ) ; } return false ; } private static checkSort ( pkg , fix ) { const result = sortPackageJson ( pkg . packageJson ) ; if ( JSON . stringify ( result ) !== JSON . stringify ( pkg . packageJson ) ) { this . logWarn ( pkg , `package.json not sorted` , fix ) ; return fix ; } return false ; } private static checkLintScripts ( pkg , fix ) { let fixed = false ; if ( pkg . getScript ( "build" ) ) { const hasPrettier = pkg . getScript ( "prettier" ) ; const lintChildren = hasPrettier ? [ "prettier" , "eslint" ] : [ "eslint" ] ; if ( this . checkChildrenScripts ( pkg , "lint" , lintChildren , false , fix ) ) { fixed = true ; } if ( this . checkChildrenScripts ( pkg , "lint:fix" , lintChildren . map ( ( value ) => `${ value }:fix` ) , false , fix ) ) { fixed = true ; } const eslintScript = pkg . getScript ( "eslint" ) ; const hasFormatStylish = eslintScript && eslintScript . search ( "--format stylish" ) >= 0 ; const command = hasFormatStylish ? "eslint --format stylish" : "eslint" ; const lintOnlySrc = eslintScript === `${ command } src` ; const dirs = ! lintOnlySrc && existsSync ( path . join ( pkg . directory , "tests" ) ) ? "src tests" : "src" ; const expectedEslintScript = `${ command } ${ dirs }` ; if ( this . checkScript ( pkg , "eslint" , expectedEslintScript , fix ) ) { fixed = true ; } if ( this . checkScript ( pkg , "eslint:fix" , `${ expectedEslintScript } --fix` , fix ) ) { fixed = true ; } } return fixed ; } public static async checkNpmIgnore ( pkg , fix ) { if ( pkg . packageJson . private || pkg . getScript ( "build" ) === undefined ) { return ; } const filename = path . join ( pkg . directory , ".npmignore" ) ; const expectedCommon = [ "nyc" , "*.log" , "**/*.tsbuildinfo" , ] ; if ( pkg . getScript ( "build:docs" ) ) { expectedCommon . push ( "**/_api-extractor-temp/**" ) ; } const testPackage = pkg . name . startsWith ( "@fluidframework/test-" ) || pkg . name . startsWith ( "@fluid-internal/test-" ) ; const expected = testPackage ? expectedCommon : [ ... expectedCommon , "src/test" , "dist/test" , ] ; if ( ! existsSync ( filename ) ) { this . logWarn ( pkg , `.npmignore does not exist` , fix ) ; if ( fix ) { await writeFileAsync ( filename , expected . join ( "\n" ) , "utf8" ) ; } } else { const content = await readFileAsync ( filename , "utf8" ) ; const split = content . split ( /\r?\n/ ) ; if ( split . length !== 0 && split [ split . length - 1 ] === "" ) { split . pop ( ) ; } for ( const v of expected ) { if ( ! split . includes ( v ) ) { this . logWarn ( pkg , `.npmignore missing "${ v }"` , fix ) ; if ( fix ) { split . push ( v ) ; } } } if ( fix ) { if ( split . length !== 0 && split [ split . length - 1 ] !== "" ) { split . push ( "" ) ; } const ret = split . join ( "\n" ) ; if ( ret !== content ) { await writeFileAsync ( filename , ret , "utf8" ) ; } } } } private static readonly commonConfig = "@fluidframework/build-common/ts-common-config.json" ; private static async checkTsConfigExtend ( pkg , fix , configJson , configFile ) { let changed = false ; if ( configJson . extends !== this . commonConfig ) { this . logWarn ( pkg , `${ configFile }: tsc config not extending ts-common-config.json` , fix ) ; if ( fix ) { configJson . extends = this . commonConfig ; changed = true ; } } if ( configJson . extends === this . commonConfig ) { let loaded = false ; const commonConfigFullPath = resolveNodeModule ( pkg . directory , this . commonConfig ) ; if ( commonConfigFullPath ) { const commonConfigJson = TscUtils . readConfigFile ( commonConfigFullPath ) ; if ( commonConfigJson ) { loaded = true ; for ( const option in configJson . compilerOptions ) { if ( configJson . compilerOptions [ option ] === commonConfigJson . compilerOptions [ option ] ) { this . logWarn ( pkg , `${ configFile }: duplicate compilerOptions - ${ option }: ${ configJson . compilerOptions [ option ] }` , fix ) ; if ( fix ) { delete configJson . compilerOptions [ option ] ; changed = true ; } } } } } if ( ! loaded ) { this . logWarn ( pkg , `${ configFile }: can't find ${ this . commonConfig }` , false ) ; } } return changed ; } public static async checkTsConfig ( pkg , fix ) { const command = pkg . getScript ( "tsc" ) ; if ( command ) { const parsedCommand = TscUtils . parseCommandLine ( command ) ; if ( ! parsedCommand ) { return ; } const configFile = TscUtils . findConfigFile ( pkg . directory , parsedCommand ) ; const configJson = TscUtils . readConfigFile ( configFile ) ; if ( configJson === undefined ) { this . logWarn ( pkg , `Failed to load config file '${ configFile }'` , false ) ; return ; } let changed = false ; if ( await this . checkTsConfigExtend ( pkg , fix , configJson , configFile ) ) { changed = true ; } if ( this . splitTestBuild ( pkg ) ) { if ( ! configJson . compilerOptions ) { configJson . compilerOptions = { } ; } if ( this . checkProperty ( configFile , pkg , configJson . compilerOptions , "composite" , true , fix ) ) { changed = true ; } const types = configJson . compilerOptions . types ; if ( types && types . includes ( "mocha" ) && ! pkg . name . startsWith ( "@fluid-tools/" ) ) { this . logWarn ( pkg , "tsc config for main src shouldn't depend on mocha" , fix ) ; if ( fix ) { const newTypes = types . filter ( ( v ) => v !== "mocha" ) ; if ( newTypes . length === 0 ) { delete configJson . compilerOptions . types ; } else { configJson . compilerOptions . types = newTypes ; } changed = true ; } } const exclude = [ "src/test/**/*" ] ; if ( this . checkProperty ( configFile , pkg , configJson , "exclude" , exclude , fix ) ) { changed = true ; } } if ( changed ) { await writeFileAsync ( configFile , JSON . stringify ( configJson , undefined , 4 ) ) ; } } } private static checkProperty < T > ( file , pkg , configJson , name , value , fix ) { if ( ! isEqual ( configJson [ name ] , value ) ) { this . logWarn ( pkg , `Unexpected ${ name } value in ${ file }` , fix ) ; if ( fix ) { configJson [ name ] = value ; return true ; } } return false ; } private static async checkOneTestDir ( pkg , fix , subDir ) { const configFile = path . join ( this . getTestBaseDir ( pkg ) , subDir , "tsconfig.json" ) ; const outDir = subDir ? `../../../dist/test/${ subDir }` : `../../dist/test` ; const referencePath = subDir ? "../../.." : "../.." ; const compilerOptions = { rootDir : "./" , outDir , types : [ "node" , ... ( subDir === "jest" ? [ "jest" , "jest-environment-puppeteer" , "puppeteer" ] : [ "mocha" ] ) ] } ; let configJson ; let changed = false ; configJson = TscUtils . readConfigFile ( configFile ) ; if ( ! configJson ) { this . logWarn ( pkg , `Unable to read ${ configFile }` , false ) ; return ; } if ( await this . checkTsConfigExtend ( pkg , fix , configJson , configFile ) ) { changed = true ; } if ( ! configJson . compilerOptions ) { this . logWarn ( pkg , `Missing compilerOptions in ${ configFile }` , fix ) ; if ( fix ) { configJson . compilerOptions = compilerOptions ; changed = true ; } } else { if ( this . checkProperty ( configFile , pkg , configJson . compilerOptions , "outDir" , outDir , fix ) ) { changed = true ; } } if ( this . hasMainBuild ( pkg ) ) { const references = [ { path : referencePath } ] ; if ( ! isEqual ( configJson . references , references ) ) { this . logWarn ( pkg , `Unexpected references in ${ configFile }` , fix ) ; if ( fix ) { configJson . references = references ; changed = true ; } } } if ( changed ) { await writeFileAsync ( configFile , JSON . stringify ( configJson , undefined , 4 ) ) ; } } public static async checkTestDir ( pkg , fix ) { const testSrcDirs = this . getTestDirs ( pkg ) ; await Promise . all ( testSrcDirs . map ( ( dir ) => this . checkOneTestDir ( pkg , fix , dir ) ) ) ; } public static splitTestBuild ( pkg , warnNoSource = false ) { if ( ! this . hasMainBuild ( pkg ) ) { return false ; } const testDirs = this . getTestDirs ( pkg , warnNoSource ) ; return testDirs . length !== 0 ; } private static hasMainBuild ( pkg ) { return existsSync ( path . join ( pkg . directory , "tsconfig.json" ) ) ; } private static getTestBaseDir ( pkg ) { return path . join ( pkg . directory , "src" , "test" ) ; } private static getTestDirs ( pkg , warnNoSource = false ) { const testSrcBaseDir = this . getTestBaseDir ( pkg ) ; if ( this . isTestDir ( pkg , testSrcBaseDir , warnNoSource ) ) { return [ "" ] ; } const testSrcDirNames = [ "mocha" , "jest" , "types" ] ; return testSrcDirNames . filter ( ( maybeTestDirName ) => { const maybeTestDir = path . join ( testSrcBaseDir , maybeTestDirName ) ; return this . isTestDir ( pkg , maybeTestDir , warnNoSource ) ; } ) ; } private static isTestDir ( pkg , maybeTestDir , warnNoSource ) { if ( ! existsSync ( path . join ( maybeTestDir , "tsconfig.json" ) ) ) { return false ; } const dirs = [ maybeTestDir ] ; while ( true ) { const dir = dirs . pop ( ) ; if ( ! dir ) { if ( warnNoSource ) { this . logWarn ( pkg , "src/test/tsconfig.json exists, but no test file detected" , false ) ; } return false ; } if ( dir !== maybeTestDir && existsSync ( path . join ( dir , "tsconfig.json" ) ) ) { continue ; } const files = fs . readdirSync ( dir , { withFileTypes : true } ) ; if ( files . some ( ( dirent ) => ! dirent . isDirectory ( ) && dirent . name . endsWith ( ".ts" ) ) ) { return true ; } dirs . push ( ... files . filter ( ( dirent ) => dirent . isDirectory ( ) ) . map ( ( dirent ) => path . join ( dir , dirent . name ) ) ) ; } } } ;