diff --git a/apps/sparo-lib/src/services/GitCloneService.ts b/apps/sparo-lib/src/services/GitCloneService.ts index 77e8ff4..3ffd2b2 100644 --- a/apps/sparo-lib/src/services/GitCloneService.ts +++ b/apps/sparo-lib/src/services/GitCloneService.ts @@ -57,7 +57,9 @@ an empty directory.`); terminal.writeDebugLine('full clone start...'); const cloneArgs: string[] = ['clone', repository, directory]; const result: child_process.SpawnSyncReturns = this._gitService.executeGitCommand({ - args: cloneArgs + args: cloneArgs, + // This is clone command, no need to find git folder + workingDirectory: process.cwd() }); if (result?.status) { throw new Error(`git clone failed with exit code ${result.status}`); @@ -79,7 +81,9 @@ an empty directory.`); directory ]; const result: child_process.SpawnSyncReturns = this._gitService.executeGitCommand({ - args: cloneArgs + args: cloneArgs, + // This is clone command, no need to find git folder + workingDirectory: process.cwd() }); if (result?.status) { throw new Error(`git clone failed with exit code ${result.status}`); @@ -101,7 +105,9 @@ an empty directory.`); directory ]; const result: child_process.SpawnSyncReturns = this._gitService.executeGitCommand({ - args: cloneArgs + args: cloneArgs, + // This is clone command, no need to find git folder + workingDirectory: process.cwd() }); if (result?.status) { throw new Error(`git clone failed with exit code ${result.status}`); diff --git a/build-tests/sparo-output-test/package.json b/build-tests/sparo-output-test/package.json index d5b6105..d3e6098 100644 --- a/build-tests/sparo-output-test/package.json +++ b/build-tests/sparo-output-test/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@rushstack/node-core-library": "~3.64.2", - "sparo": "workspace:*", - "jest-diff": "~29.7.0" + "build-test-utilities": "workspace:*", + "sparo": "workspace:*" }, "devDependencies": { "@rushstack/heft": "0.64.3", diff --git a/build-tests/sparo-output-test/src/start-test.ts b/build-tests/sparo-output-test/src/start-test.ts index f1bf7be..ec7b0af 100644 --- a/build-tests/sparo-output-test/src/start-test.ts +++ b/build-tests/sparo-output-test/src/start-test.ts @@ -1,24 +1,10 @@ -import * as path from 'path'; -import { Async, Executable, FileSystem, type FolderItem, Text } from '@rushstack/node-core-library'; -import { diff } from 'jest-diff'; -import type { SpawnSyncReturns } from 'child_process'; +import { + ICommandDefinition, + executeCommandsAndCollectOutputs, + updateOrCompareOutputs +} from 'build-test-utilities'; import type { IRunScriptOptions } from '@rushstack/heft'; -interface IScenarioDefinition { - /** - * The scenario name. It is used to generate the output file name. - * - * For example, if the name is "top-level-help", the output file name will be "top-level-help.txt". - */ - name: string; - /** - * The command line arguments to run. This doesn't include the command name itself. - * - * For example, if the command is "sparo clone --help", the args will be ["clone", "--help"]. - */ - args: string[]; -} - /** * This build test is highly inspired by the build test for api-extractor in rushstack. */ @@ -30,184 +16,44 @@ export async function runAsync(runScriptOptions: IRunScriptOptions): Promise = Executable.spawnSync(binPath, args, { - environment: { - ...process.env, - // Always use color for the output - FORCE_COLOR: 'true' - } - }); + await executeCommandsAndCollectOutputs({ + buildFolderPath, + commandDefinitions + }); - if (result.status !== 0) { - throw new Error( - `Failed to run "sparo ${args.join(' ')}" with exit code ${result.status}\n${result.stderr}` - ); - } - - const outputPath: string = path.join(tempFolder, `${name}.txt`); - FileSystem.writeFile( - outputPath, - `Running "sparo ${args.join(' ')}":\n${processVersionString(result.stdout)}` - ); - } - - /** - * Files under outFolderPath are tracked by Git, files under inFolderPath are temporary files. During a local build, - * --production is false, temporary files are copied to outFolderPath. During a CI build, --production is true, the - * files with same name under these two folders are compared and CI build fails if they are different. - * - * This ensures that the temporary files must be up to date in the PR, and people who review the PR must approve any - * changes. - */ - const inFolderPath: string = tempFolder; - const outFolderPath: string = `${buildFolderPath}/etc`; - await FileSystem.ensureFolderAsync(outFolderPath); - - const inFolderPaths: AsyncIterable = enumerateFolderPaths(inFolderPath, ''); - const outFolderPaths: AsyncIterable = enumerateFolderPaths(outFolderPath, ''); - const outFolderPathsSet: Set = new Set(); - - for await (const outFolderPath of outFolderPaths) { - outFolderPathsSet.add(outFolderPath); - } - - const nonMatchingFiles: string[] = []; - const nonMatchingFileErrorMessages: Map = new Map(); - await Async.forEachAsync( - inFolderPaths, - async (folderItemPath: string) => { - outFolderPathsSet.delete(folderItemPath); - - const sourceFileContents: string = await FileSystem.readFileAsync(inFolderPath + folderItemPath); - const outFilePath: string = outFolderPath + folderItemPath; - - let outFileContents: string | undefined; - try { - outFileContents = await FileSystem.readFileAsync(outFilePath); - } catch (e) { - if (!FileSystem.isNotExistError(e)) { - throw e; - } - } - - const normalizedSourceFileContents: string = Text.convertToLf(sourceFileContents); - const normalizedOutFileContents: string | undefined = outFileContents - ? Text.convertToLf(outFileContents) - : undefined; - - if (normalizedSourceFileContents !== normalizedOutFileContents) { - nonMatchingFiles.push(outFilePath); - if (production) { - // Display diff only when running in production mode, mostly for CI build - nonMatchingFileErrorMessages.set( - outFilePath, - diff(normalizedOutFileContents, normalizedSourceFileContents) || '' - ); - } - if (!production) { - await FileSystem.writeFileAsync(outFilePath, normalizedSourceFileContents, { - ensureFolderExists: true - }); - } - } - }, - { - concurrency: 10 - } - ); - - if (outFolderPathsSet.size > 0) { - nonMatchingFiles.push(...outFolderPathsSet); - if (!production) { - await Async.forEachAsync( - outFolderPathsSet, - async (outFolderPath) => { - await FileSystem.deleteFileAsync(`${outFolderPath}/${outFolderPath}`); - }, - { concurrency: 10 } - ); - } - } - - if (nonMatchingFiles.length > 0) { - const errorLines: string[] = []; - for (const nonMatchingFile of nonMatchingFiles.sort()) { - errorLines.push(` ${nonMatchingFile}`); - const errorMessage: string | undefined = nonMatchingFileErrorMessages.get(nonMatchingFile); - if (errorMessage) { - errorLines.push(`${errorMessage}`); - } - } - - if (production) { - logger.emitError( - new Error( - 'The following file(s) do not match the expected output. Build this project in non-production ' + - `mode and commit the changes:\n${errorLines.join('\n')}` - ) - ); - } else { - logger.emitWarning( - new Error( - `The following file(s) do not match the expected output and must be committed to Git:\n` + - errorLines.join('\n') - ) - ); - } - } -} - -/** - * Replace all x.y.z version strings with __VERSION__. - */ -function processVersionString(text: string): string { - return text.replace(/\d+\.\d+\.\d+/g, '__VERSION__'); -} - -async function* enumerateFolderPaths( - absoluteFolderPath: string, - relativeFolderPath: string -): AsyncIterable { - const folderItems: FolderItem[] = await FileSystem.readFolderItemsAsync(absoluteFolderPath); - for (const folderItem of folderItems) { - const childRelativeFolderPath: string = `${relativeFolderPath}/${folderItem.name}`; - if (folderItem.isDirectory()) { - yield* enumerateFolderPaths(`${absoluteFolderPath}/${folderItem.name}`, childRelativeFolderPath); - } else { - yield childRelativeFolderPath; - } - } + await updateOrCompareOutputs({ + buildFolderPath, + logger, + production + }); } diff --git a/build-tests/sparo-real-repo-test/.eslintrc.js b/build-tests/sparo-real-repo-test/.eslintrc.js new file mode 100644 index 0000000..612bb5d --- /dev/null +++ b/build-tests/sparo-real-repo-test/.eslintrc.js @@ -0,0 +1,19 @@ +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('@rushstack/heft-node-rig/profiles/default/includes/eslint/patch/modern-module-resolution'); +// This is a workaround for https://github.com/microsoft/rushstack/issues/3021 +require('@rushstack/heft-node-rig/profiles/default/includes/eslint/patch/custom-config-package-names'); + +module.exports = { + extends: [ + '@rushstack/heft-node-rig/profiles/default/includes/eslint/profile/node-trusted-tool', + '@rushstack/heft-node-rig/profiles/default/includes/eslint/mixins/friendly-locals' + ], + parserOptions: { tsconfigRootDir: __dirname }, + + overrides: [ + { + files: ['*.ts', '*.tsx'], + rules: {} + } + ] +}; diff --git a/build-tests/sparo-real-repo-test/LICENSE b/build-tests/sparo-real-repo-test/LICENSE new file mode 100644 index 0000000..3bd1dad --- /dev/null +++ b/build-tests/sparo-real-repo-test/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) TikTok Pte. Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build-tests/sparo-real-repo-test/README.md b/build-tests/sparo-real-repo-test/README.md new file mode 100644 index 0000000..e33eac7 --- /dev/null +++ b/build-tests/sparo-real-repo-test/README.md @@ -0,0 +1,11 @@ +# sparo-output-test + +Building this project tests sparo command outputs + +# Details + +`lib/start-test.js` is run after building the project. This scripts generate the output text files under `temp/etc`. In local builds, those files are copied to `etc` folder. During a CI build, the files under these two folders are compared and the CI build fails if they are different. This ensures that files under `etc` folder must be up to date in the PR, and people who review the PR must approve any changes. + +# How to fix the build errors + +Run `rush build -t sparo-output-test` to regenerate files under `etc` folder and commit them into Git. \ No newline at end of file diff --git a/build-tests/sparo-real-repo-test/config/heft.json b/build-tests/sparo-real-repo-test/config/heft.json new file mode 100644 index 0000000..acf3ab4 --- /dev/null +++ b/build-tests/sparo-real-repo-test/config/heft.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + + "extends": "@rushstack/heft-node-rig/profiles/default/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "post-compile": { + "taskDependencies": ["typescript"], + + "taskPlugin": { + "pluginName": "run-script-plugin", + "pluginPackage": "@rushstack/heft", + + "options": { + "scriptPath": "lib/start-test.js" + } + } + } + } + } + } +} diff --git a/build-tests/sparo-real-repo-test/config/rig.json b/build-tests/sparo-real-repo-test/config/rig.json new file mode 100644 index 0000000..58032e0 --- /dev/null +++ b/build-tests/sparo-real-repo-test/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "@rushstack/heft-node-rig" +} diff --git a/build-tests/sparo-real-repo-test/etc/checkout-empty-profile.txt b/build-tests/sparo-real-repo-test/etc/checkout-empty-profile.txt new file mode 100644 index 0000000..49232a5 --- /dev/null +++ b/build-tests/sparo-real-repo-test/etc/checkout-empty-profile.txt @@ -0,0 +1,24 @@ +Running "sparo checkout --profile my-team": + +Sparo accelerator for Git __VERSION__ - https://tiktok.github.io/sparo/ +Node.js version is __VERSION__ (LTS) +Git version is __VERSION__ + + +--[ git checkout ]------------------------------------------------------------- +Your branch is up to date with 'origin/main'. +------------------------------------------------------------------------------- + +Syncing checkout with the Sparo profile: my-team + +Checking out and updating core files... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Checking out skeleton... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Sparse checkout completed in __DURATION__ diff --git a/build-tests/sparo-real-repo-test/etc/checkout-profile.txt b/build-tests/sparo-real-repo-test/etc/checkout-profile.txt new file mode 100644 index 0000000..9c76948 --- /dev/null +++ b/build-tests/sparo-real-repo-test/etc/checkout-profile.txt @@ -0,0 +1,29 @@ +Running "sparo checkout --profile my-team": + +Sparo accelerator for Git __VERSION__ - https://tiktok.github.io/sparo/ +Node.js version is __VERSION__ (LTS) +Git version is __VERSION__ + + +--[ git checkout ]------------------------------------------------------------- +Your branch is up to date with 'origin/main'. +------------------------------------------------------------------------------- + +Syncing checkout with the Sparo profile: my-team + +Checking out and updating core files... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Checking out skeleton... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Checking out __FOLDER_COUNT__ folders... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Sparse checkout completed in __DURATION__ diff --git a/build-tests/sparo-real-repo-test/etc/clone.txt b/build-tests/sparo-real-repo-test/etc/clone.txt new file mode 100644 index 0000000..0d0ea74 --- /dev/null +++ b/build-tests/sparo-real-repo-test/etc/clone.txt @@ -0,0 +1,40 @@ +Running "sparo clone git@github.com:Azure/azure-sdk-for-js.git": + +Sparo accelerator for Git __VERSION__ - https://tiktok.github.io/sparo/ +Node.js version is __VERSION__ (LTS) +Git version is __VERSION__ + +Initializing working directory... + +--[ git clone ]---------------------------------------------------------------- +------------------------------------------------------------------------------- + +Checking out and updating core files... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Checking out skeleton... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Applying recommended configuration... + +--[ git maintenance ]---------------------------------------------------------- +------------------------------------------------------------------------------- + +Success: Working directory "azure-sdk-for-js" was prepared in __DURATION__. + +Don't forget to change your shell path: + cd azure-sdk-for-js + +Your next step is to choose a Sparo profile for checkout. +To see available profiles in this repo: + sparo list-profiles +To checkout and set profile: + sparo checkout --profile  +To checkout and add profile: + sparo checkout --add-profile  +To create a new profile: + sparo init-profile --profile  diff --git a/build-tests/sparo-real-repo-test/etc/init-profile.txt b/build-tests/sparo-real-repo-test/etc/init-profile.txt new file mode 100644 index 0000000..b096180 --- /dev/null +++ b/build-tests/sparo-real-repo-test/etc/init-profile.txt @@ -0,0 +1,11 @@ +Running "sparo init-profile --profile my-team": + +Sparo accelerator for Git __VERSION__ - https://tiktok.github.io/sparo/ +Node.js version is __VERSION__ (LTS) +Git version is __VERSION__ + +Successfully initialized the "my-team" profile. + +Next step: Open this file in your editor and configure the project selectors: + + __WORKING_DIRECTORY__/common/sparo-profiles/my-team.json diff --git a/build-tests/sparo-real-repo-test/etc/list-profiles-with-project.txt b/build-tests/sparo-real-repo-test/etc/list-profiles-with-project.txt new file mode 100644 index 0000000..85339eb --- /dev/null +++ b/build-tests/sparo-real-repo-test/etc/list-profiles-with-project.txt @@ -0,0 +1,20 @@ +Running "sparo list-profiles --project @azure/core-auth": + +Sparo accelerator for Git __VERSION__ - https://tiktok.github.io/sparo/ +Node.js version is __VERSION__ (LTS) +Git version is __VERSION__ + +Listing profiles... + +Checking out and updating core files... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Checking out skeleton... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +@azure/core-auth was included in the below profiles: + my-team diff --git a/build-tests/sparo-real-repo-test/etc/list-profiles.txt b/build-tests/sparo-real-repo-test/etc/list-profiles.txt new file mode 100644 index 0000000..0593252 --- /dev/null +++ b/build-tests/sparo-real-repo-test/etc/list-profiles.txt @@ -0,0 +1,20 @@ +Running "sparo list-profiles": + +Sparo accelerator for Git __VERSION__ - https://tiktok.github.io/sparo/ +Node.js version is __VERSION__ (LTS) +Git version is __VERSION__ + +Listing profiles... + +Checking out and updating core files... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +Checking out skeleton... + +--[ git sparse-checkout ]------------------------------------------------------ +------------------------------------------------------------------------------- + +All available profiles: +my-team diff --git a/build-tests/sparo-real-repo-test/package.json b/build-tests/sparo-real-repo-test/package.json new file mode 100644 index 0000000..2451e4a --- /dev/null +++ b/build-tests/sparo-real-repo-test/package.json @@ -0,0 +1,25 @@ +{ + "name": "sparo-real-repo-test", + "description": "Building this project tests sparo in a real repository", + "version": "1.0.0", + "private": true, + "scripts": { + "_phase:build": "heft run --only build -- --clean", + "build": "heft build --clean" + }, + "dependencies": { + "@rushstack/node-core-library": "~3.64.2", + "build-test-utilities": "workspace:*", + "sparo": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "0.64.3", + "@rushstack/heft-node-rig": "2.4.5", + "@types/heft-jest": "1.0.6", + "@types/node": "20.11.16", + "eslint": "8.56.0", + "typescript": "~5.3.3" + }, + "keywords": [], + "license": "MIT" +} diff --git a/build-tests/sparo-real-repo-test/src/start-test.ts b/build-tests/sparo-real-repo-test/src/start-test.ts new file mode 100644 index 0000000..0a7f8ba --- /dev/null +++ b/build-tests/sparo-real-repo-test/src/start-test.ts @@ -0,0 +1,113 @@ +import * as path from 'path'; +import { FileSystem } from '@rushstack/node-core-library'; +import type { IRunScriptOptions } from '@rushstack/heft'; +import { + ICommandDefinition, + executeCommandsAndCollectOutputs, + updateOrCompareOutputs +} from 'build-test-utilities'; + +/** + * This build test is highly inspired by the build test for api-extractor in rushstack. + */ +export async function runAsync(runScriptOptions: IRunScriptOptions): Promise { + const { + heftTaskSession: { + logger, + parameters: { production } + }, + heftConfiguration: { buildFolderPath } + } = runScriptOptions; + + const temporaryDirectory: string = path.resolve(buildFolderPath, 'temp'); + const testRepoURL: string = 'git@github.com:Azure/azure-sdk-for-js.git'; + const repoFolder: string = path.resolve(temporaryDirectory, 'azure-sdk-for-js'); + + await FileSystem.deleteFolderAsync(repoFolder); + + const commandDefinitions: ICommandDefinition[] = [ + // sparo clone git@github.com:Azure/azure-sdk-for-js.git + { + kind: 'sparo-command', + name: 'clone', + args: ['clone', testRepoURL], + currentWorkingDirectory: temporaryDirectory + }, + // sparo init-profile --profile my-team + { + kind: 'sparo-command', + name: 'init-profile', + args: ['init-profile', '--profile', 'my-team'], + currentWorkingDirectory: repoFolder + }, + // sparo checkout --profile my-team - extra step to checkout an empty profile + { + kind: 'sparo-command', + name: 'checkout-empty-profile', + args: ['checkout', '--profile', 'my-team'], + currentWorkingDirectory: repoFolder + }, + // Prepare my-team profile + { + kind: 'custom-callback', + name: 'prepare-my-team-profile', + callback: async () => { + await FileSystem.writeFileAsync( + path.resolve(repoFolder, 'common/sparo-profiles/my-team.json'), + JSON.stringify( + { + $schema: 'https://tiktok.github.io/sparo/schemas/sparo-profile.schema.json', + selections: [ + { + selector: '--to', + argument: '@azure/arm-commerce' + } + ], + includeFolders: [], + excludeFolders: [] + }, + null, + 2 + ) + ); + } + }, + // sparo checkout --profile my-team + { + kind: 'sparo-command', + name: 'checkout-profile', + args: ['checkout', '--profile', 'my-team'], + currentWorkingDirectory: repoFolder + }, + // sparo list-profiles + { + kind: 'sparo-command', + name: 'list-profiles', + args: ['list-profiles'], + currentWorkingDirectory: repoFolder + }, + // sparo list-profiles --project @azure/core-auth + { + kind: 'sparo-command', + name: 'list-profiles-with-project', + args: ['list-profiles', '--project', '@azure/core-auth'], + currentWorkingDirectory: repoFolder + } + ]; + + await executeCommandsAndCollectOutputs({ + buildFolderPath, + commandDefinitions + }); + + await updateOrCompareOutputs({ + buildFolderPath, + logger, + production + }); + + // Clean up the temporary directory in CI builds, but leave it for local debugging + if (production) { + await FileSystem.deleteFolderAsync(repoFolder); + } +} diff --git a/build-tests/sparo-real-repo-test/tsconfig.json b/build-tests/sparo-real-repo-test/tsconfig.json new file mode 100644 index 0000000..f0b86d0 --- /dev/null +++ b/build-tests/sparo-real-repo-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "extends": "./node_modules/@rushstack/heft-node-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "types": ["node", "heft-jest"] + } +} diff --git a/build-tests/test-utilities/.eslintrc.js b/build-tests/test-utilities/.eslintrc.js new file mode 100644 index 0000000..612bb5d --- /dev/null +++ b/build-tests/test-utilities/.eslintrc.js @@ -0,0 +1,19 @@ +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('@rushstack/heft-node-rig/profiles/default/includes/eslint/patch/modern-module-resolution'); +// This is a workaround for https://github.com/microsoft/rushstack/issues/3021 +require('@rushstack/heft-node-rig/profiles/default/includes/eslint/patch/custom-config-package-names'); + +module.exports = { + extends: [ + '@rushstack/heft-node-rig/profiles/default/includes/eslint/profile/node-trusted-tool', + '@rushstack/heft-node-rig/profiles/default/includes/eslint/mixins/friendly-locals' + ], + parserOptions: { tsconfigRootDir: __dirname }, + + overrides: [ + { + files: ['*.ts', '*.tsx'], + rules: {} + } + ] +}; diff --git a/build-tests/test-utilities/LICENSE b/build-tests/test-utilities/LICENSE new file mode 100644 index 0000000..3bd1dad --- /dev/null +++ b/build-tests/test-utilities/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) TikTok Pte. Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build-tests/test-utilities/README.md b/build-tests/test-utilities/README.md new file mode 100644 index 0000000..e33eac7 --- /dev/null +++ b/build-tests/test-utilities/README.md @@ -0,0 +1,11 @@ +# sparo-output-test + +Building this project tests sparo command outputs + +# Details + +`lib/start-test.js` is run after building the project. This scripts generate the output text files under `temp/etc`. In local builds, those files are copied to `etc` folder. During a CI build, the files under these two folders are compared and the CI build fails if they are different. This ensures that files under `etc` folder must be up to date in the PR, and people who review the PR must approve any changes. + +# How to fix the build errors + +Run `rush build -t sparo-output-test` to regenerate files under `etc` folder and commit them into Git. \ No newline at end of file diff --git a/build-tests/test-utilities/config/rig.json b/build-tests/test-utilities/config/rig.json new file mode 100644 index 0000000..58032e0 --- /dev/null +++ b/build-tests/test-utilities/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "@rushstack/heft-node-rig" +} diff --git a/build-tests/test-utilities/package.json b/build-tests/test-utilities/package.json new file mode 100644 index 0000000..0d1fdea --- /dev/null +++ b/build-tests/test-utilities/package.json @@ -0,0 +1,26 @@ +{ + "name": "build-test-utilities", + "description": "Utilities to do build test", + "version": "1.0.0", + "private": true, + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "scripts": { + "_phase:build": "heft run --only build -- --clean", + "build": "heft build --clean" + }, + "dependencies": { + "@rushstack/node-core-library": "~3.64.2", + "jest-diff": "~29.7.0" + }, + "devDependencies": { + "@rushstack/heft": "0.64.3", + "@rushstack/heft-node-rig": "2.4.5", + "@types/heft-jest": "1.0.6", + "@types/node": "20.11.16", + "eslint": "8.56.0", + "typescript": "~5.3.3" + }, + "keywords": [], + "license": "MIT" +} diff --git a/build-tests/test-utilities/src/index.ts b/build-tests/test-utilities/src/index.ts new file mode 100644 index 0000000..c349176 --- /dev/null +++ b/build-tests/test-utilities/src/index.ts @@ -0,0 +1,281 @@ +import * as path from 'path'; +import { Async, Executable, FileSystem, Text, type FolderItem } from '@rushstack/node-core-library'; +import { diff } from 'jest-diff'; +import type { SpawnSyncReturns } from 'child_process'; +import type { IScopedLogger } from '@rushstack/heft'; + +export type ICommandDefinition = ISparoCommandDefinition | ICustomCallbackDefinition; + +export interface ISparoCommandDefinition { + kind: 'sparo-command'; + /** + * The scenario name. It is used to generate the output file name. + * + * For example, if the name is "top-level-help", the output file name will be "top-level-help.txt". + */ + name: string; + /** + * The command line arguments to run. This doesn't include the command name itself. + * + * For example, if the command is "sparo clone --help", the args will be ["clone", "--help"]. + */ + args: string[]; + + /** + * The working directory + */ + currentWorkingDirectory?: string; +} + +export interface ICustomCallbackDefinition { + kind: 'custom-callback'; + /** + * Name of the custom definition + */ + name: string; + /** + * Callback function to run logic between commands + */ + callback: () => Promise; +} + +export interface IExecuteCommandsAndCollectOutputsOptions { + commandDefinitions: ICommandDefinition[]; + buildFolderPath: string; +} + +/** + * Execute a list of predefined commands, collecting the sparo outputs to a temporary folder. + */ +export async function executeCommandsAndCollectOutputs({ + commandDefinitions, + buildFolderPath +}: IExecuteCommandsAndCollectOutputsOptions): Promise { + const sparoBinPath: string = path.join(buildFolderPath, 'node_modules', '.bin', 'sparo'); + const tempFolder: string = path.join(buildFolderPath, 'temp', 'etc'); + + /** + * Run each scenario and generate outputs + */ + await FileSystem.ensureEmptyFolderAsync(tempFolder); + for (const commandListDefinition of commandDefinitions) { + const { kind } = commandListDefinition; + switch (commandListDefinition.kind) { + case 'sparo-command': { + const { name, args, currentWorkingDirectory } = commandListDefinition; + const result: SpawnSyncReturns = Executable.spawnSync(sparoBinPath, args, { + currentWorkingDirectory, + environment: { + ...process.env, + // Always use color for the output + FORCE_COLOR: 'true' + } + }); + + if (result.status !== 0) { + throw new Error( + `Failed to run "sparo ${args.join(' ')}" with exit code ${result.status}\n${result.stderr}` + ); + } + + const outputPath: string = path.join(tempFolder, `${name}.txt`); + FileSystem.writeFile( + outputPath, + `Running "sparo ${args.join(' ')}":\n${processSparoOutput( + result.stdout, + currentWorkingDirectory || process.cwd() + )}` + ); + break; + } + case 'custom-callback': { + const { name, callback } = commandListDefinition; + try { + await callback(); + } catch (e) { + throw new Error(`Failed to run custom callback function for ${name}:\n${e.message}`); + } + break; + } + default: { + throw new Error(`Unrecognized command kind: ${kind}`); + } + } + } +} + +export interface IUpdateOrCompareOutputs { + buildFolderPath: string; + production: boolean; + logger: IScopedLogger; +} + +/** + * Based on buildFolderPath, we have a inFolderPath and outFolderPath. + * + * Files under outFolderPath are tracked by Git, files under inFolderPath are temporary files. During a local build, + * --production is false, temporary files are copied to outFolderPath. During a CI build, --production is true, the + * files with same name under these two folders are compared and CI build fails if they are different. + * + * The file structure could be: + * + * -- buildFolder + * |- temp + * |- etc + * |- foo.txt + * |- etc + * |- foo.txt + * + * This ensures that the temporary files must be up to date in the PR, and people who review the PR must approve any + * changes. + */ +export async function updateOrCompareOutputs({ + buildFolderPath, + production, + logger +}: IUpdateOrCompareOutputs): Promise { + const inFolderPath: string = `${buildFolderPath}/temp/etc`; + const outFolderPath: string = `${buildFolderPath}/etc`; + await FileSystem.ensureFolderAsync(outFolderPath); + + const inFolderPaths: AsyncIterable = enumerateFolderPaths(inFolderPath, ''); + const outFolderPaths: AsyncIterable = enumerateFolderPaths(outFolderPath, ''); + const outFolderPathsSet: Set = new Set(); + + for await (const outFolderPath of outFolderPaths) { + outFolderPathsSet.add(outFolderPath); + } + + const nonMatchingFiles: string[] = []; + const nonMatchingFileErrorMessages: Map = new Map(); + await Async.forEachAsync( + inFolderPaths, + async (folderItemPath: string) => { + outFolderPathsSet.delete(folderItemPath); + + const sourceFileContents: string = await FileSystem.readFileAsync(inFolderPath + folderItemPath); + const outFilePath: string = outFolderPath + folderItemPath; + + let outFileContents: string | undefined; + try { + outFileContents = await FileSystem.readFileAsync(outFilePath); + } catch (e) { + if (!FileSystem.isNotExistError(e)) { + throw e; + } + } + + const normalizedSourceFileContents: string = Text.convertToLf(sourceFileContents); + const normalizedOutFileContents: string | undefined = outFileContents + ? Text.convertToLf(outFileContents) + : undefined; + + if (normalizedSourceFileContents !== normalizedOutFileContents) { + nonMatchingFiles.push(outFilePath); + if (production) { + // Display diff only when running in production mode, mostly for CI build + nonMatchingFileErrorMessages.set( + outFilePath, + diff(normalizedOutFileContents, normalizedSourceFileContents) || '' + ); + } + if (!production) { + await FileSystem.writeFileAsync(outFilePath, normalizedSourceFileContents, { + ensureFolderExists: true + }); + } + } + }, + { + concurrency: 10 + } + ); + + if (outFolderPathsSet.size > 0) { + nonMatchingFiles.push(...outFolderPathsSet); + if (!production) { + await Async.forEachAsync( + outFolderPathsSet, + async (outFolderPath) => { + await FileSystem.deleteFileAsync(`${outFolderPath}/${outFolderPath}`); + }, + { concurrency: 10 } + ); + } + } + + if (nonMatchingFiles.length > 0) { + const errorLines: string[] = []; + for (const nonMatchingFile of nonMatchingFiles.sort()) { + errorLines.push(` ${nonMatchingFile}`); + const errorMessage: string | undefined = nonMatchingFileErrorMessages.get(nonMatchingFile); + if (errorMessage) { + errorLines.push(`${errorMessage}`); + } + } + + if (production) { + logger.emitError( + new Error( + 'The following file(s) do not match the expected output. Build this project in non-production ' + + `mode and commit the changes:\n${errorLines.join('\n')}` + ) + ); + } else { + logger.emitWarning( + new Error( + `The following file(s) do not match the expected output and must be committed to Git:\n` + + errorLines.join('\n') + ) + ); + } + } +} + +function processSparoOutput(text: string, workingDirectory: string): string { + return [ + replaceVersionString, + replaceDurationString, + replaceWorkingDirectoryPath, + replaceFolderCountString + ].reduce((text, fn) => fn(text, workingDirectory), text); +} +/** + * Replace all x.y.z version strings with __VERSION__. + */ +function replaceVersionString(text: string): string { + return text.replace(/\d+\.\d+\.\d+/g, '__VERSION__'); +} +/** + * Replace all "in xx.yy seconds" strings with "in __DURATION__ seconds". + */ +function replaceDurationString(text: string): string { + return text.replace(/in \d+(\.\d+)? seconds/g, 'in __DURATION__'); +} +/** + * Replace all "" strings with "__WORKING_DIRECTORY__". + */ +function replaceWorkingDirectoryPath(text: string, workingDirectory: string): string { + return text.replace(new RegExp(workingDirectory, 'g'), '__WORKING_DIRECTORY__'); +} +/** + * Replace "Checking out x folders" with "Checking out __FOLDER_COUNT__ folders". + */ +function replaceFolderCountString(text: string): string { + return text.replace(/Checking out \d+ folders/g, 'Checking out __FOLDER_COUNT__ folders'); +} + +async function* enumerateFolderPaths( + absoluteFolderPath: string, + relativeFolderPath: string +): AsyncIterable { + const folderItems: FolderItem[] = await FileSystem.readFolderItemsAsync(absoluteFolderPath); + for (const folderItem of folderItems) { + const childRelativeFolderPath: string = `${relativeFolderPath}/${folderItem.name}`; + if (folderItem.isDirectory()) { + yield* enumerateFolderPaths(`${absoluteFolderPath}/${folderItem.name}`, childRelativeFolderPath); + } else { + yield childRelativeFolderPath; + } + } +} diff --git a/build-tests/test-utilities/tsconfig.json b/build-tests/test-utilities/tsconfig.json new file mode 100644 index 0000000..f0b86d0 --- /dev/null +++ b/build-tests/test-utilities/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "extends": "./node_modules/@rushstack/heft-node-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "types": ["node", "heft-jest"] + } +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 46081a7..777e4a0 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -154,9 +154,9 @@ importers: '@rushstack/node-core-library': specifier: ~3.64.2 version: 3.64.2(@types/node@20.11.16) - jest-diff: - specifier: ~29.7.0 - version: 29.7.0 + build-test-utilities: + specifier: workspace:* + version: link:../test-utilities sparo: specifier: workspace:* version: link:../../apps/sparo @@ -180,6 +180,65 @@ importers: specifier: ~5.3.3 version: 5.3.3 + ../../build-tests/sparo-real-repo-test: + dependencies: + '@rushstack/node-core-library': + specifier: ~3.64.2 + version: 3.64.2(@types/node@20.11.16) + build-test-utilities: + specifier: workspace:* + version: link:../test-utilities + sparo: + specifier: workspace:* + version: link:../../apps/sparo + devDependencies: + '@rushstack/heft': + specifier: 0.64.3 + version: 0.64.3(@types/node@20.11.16) + '@rushstack/heft-node-rig': + specifier: 2.4.5 + version: 2.4.5(@rushstack/heft@0.64.3)(@types/node@20.11.16) + '@types/heft-jest': + specifier: 1.0.6 + version: 1.0.6 + '@types/node': + specifier: 20.11.16 + version: 20.11.16 + eslint: + specifier: 8.56.0 + version: 8.56.0 + typescript: + specifier: ~5.3.3 + version: 5.3.3 + + ../../build-tests/test-utilities: + dependencies: + '@rushstack/node-core-library': + specifier: ~3.64.2 + version: 3.64.2(@types/node@20.11.16) + jest-diff: + specifier: ~29.7.0 + version: 29.7.0 + devDependencies: + '@rushstack/heft': + specifier: 0.64.3 + version: 0.64.3(@types/node@20.11.16) + '@rushstack/heft-node-rig': + specifier: 2.4.5 + version: 2.4.5(@rushstack/heft@0.64.3)(@types/node@20.11.16) + '@types/heft-jest': + specifier: 1.0.6 + version: 1.0.6 + '@types/node': + specifier: 20.11.16 + version: 20.11.16 + eslint: + specifier: 8.56.0 + version: 8.56.0 + typescript: + specifier: ~5.3.3 + version: 5.3.3 + packages: /@aashutoshrathi/word-wrap@1.2.6: diff --git a/common/sparo-profiles/my-team.json b/common/sparo-profiles/my-team.json new file mode 100644 index 0000000..e4a3f3c --- /dev/null +++ b/common/sparo-profiles/my-team.json @@ -0,0 +1,50 @@ +/** + * OWNER: + * PURPOSE: + */ +{ + "$schema": "https://tiktok.github.io/sparo/schemas/sparo-profile.schema.json", + + /** + * A list of Rush project selectors indicating the project folders to be + * included for sparse checkout. The selectors will be combined to make + * the union superset of projects. See the Rush selector docs for details: + * https://rushjs.io/pages/developer/selecting_subsets/ + */ + "selections": [ + /** + * For example, include all Rush projects tagged with "tag:my-team" + * as well as the dependency workspace projects needed to build them. + */ + // { + // "selector": "--to", + // "argument": "tag:my-team" + // }, + /** + * For example, include the project called "my-library", as well as all + * projects that are impacted by changes to it, as well as the dependency + * projects needed to build everything. + */ + // { + // "selector": "--from", + // "argument": "my-library" + // } + ], + + /** + * A list of arbitrary additional folders to be included for checkout, + * not necessarily corresponding to any workspace project. + */ + "includeFolders": [ + // "path/to/include" + ], + + /** + * A list of folders to be excluded from the checkout. This field takes precedence + * over the "includeFolders" and "selections" fields, guaranteeing that the + * specified path will definitely not be included. + */ + "excludeFolders": [ + // "path/to/exclude" + ] +} diff --git a/rush.json b/rush.json index d72a82a..b0487c2 100644 --- a/rush.json +++ b/rush.json @@ -440,6 +440,14 @@ "packageName": "sparo-output-test", "projectFolder": "build-tests/sparo-output-test" }, + { + "packageName": "sparo-real-repo-test", + "projectFolder": "build-tests/sparo-real-repo-test" + }, + { + "packageName": "build-test-utilities", + "projectFolder": "build-tests/test-utilities" + }, // Sparo {