feat: build test for a real repo

This commit is contained in:
Cheng Liu 2024-03-06 17:25:24 -08:00
parent 7cf0c1443f
commit ab7cefefca
No known key found for this signature in database
GPG key ID: EEC8452F7DB85CD6
27 changed files with 892 additions and 182 deletions

View file

@ -57,7 +57,9 @@ an empty directory.`);
terminal.writeDebugLine('full clone start...');
const cloneArgs: string[] = ['clone', repository, directory];
const result: child_process.SpawnSyncReturns<string> = 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<string> = 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<string> = 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}`);

View file

@ -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",

View file

@ -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<voi
},
heftConfiguration: { buildFolderPath }
} = runScriptOptions;
const binPath: string = path.join(buildFolderPath, 'node_modules', '.bin', 'sparo');
const tempFolder: string = path.join(buildFolderPath, 'temp', 'etc');
const scenarios: IScenarioDefinition[] = [
const commandDefinitions: ICommandDefinition[] = [
{
kind: 'sparo-command',
name: 'top-level-help',
args: ['--help']
},
{
kind: 'sparo-command',
name: 'clone-help',
args: ['clone', '--help']
},
{
kind: 'sparo-command',
name: 'checkout-help',
args: ['checkout', '--help']
},
{
kind: 'sparo-command',
name: 'top-level-nonexistent-command',
args: ['nonexistent-command']
}
// FIXME: This is currently broken -- it simply ignores the unrecognized parameter
// {
// kind: 'sparo-command',
// name: 'checkout-nonexistent-parameter',
// args: ['checkout', '--nonexistent-parameter']
// }
];
/**
* Run each scenario and generate outputs
*/
await FileSystem.ensureEmptyFolderAsync(tempFolder);
for (const scenario of scenarios) {
const { name, args } = scenario;
const result: SpawnSyncReturns<string> = 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<string> = enumerateFolderPaths(inFolderPath, '');
const outFolderPaths: AsyncIterable<string> = enumerateFolderPaths(outFolderPath, '');
const outFolderPathsSet: Set<string> = new Set<string>();
for await (const outFolderPath of outFolderPaths) {
outFolderPathsSet.add(outFolderPath);
}
const nonMatchingFiles: string[] = [];
const nonMatchingFileErrorMessages: Map<string, string> = new Map<string, string>();
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
await updateOrCompareOutputs({
buildFolderPath,
logger,
production
});
}
}
},
{
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<string> {
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;
}
}
}

View file

@ -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: {}
}
]
};

View file

@ -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.

View file

@ -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.

View file

@ -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"
}
}
}
}
}
}
}

View file

@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@rushstack/heft-node-rig"
}

View file

@ -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__

View file

@ -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__

View file

@ -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 <profile_name>
To checkout and add profile:
sparo checkout --add-profile <profile_name>
To create a new profile:
sparo init-profile --profile <profile_name>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
}

View file

@ -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<void> {
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);
}
}

View file

@ -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"]
}
}

View file

@ -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: {}
}
]
};

View file

@ -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.

View file

@ -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.

View file

@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@rushstack/heft-node-rig"
}

View file

@ -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"
}

View file

@ -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<void>;
}
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<void> {
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<string> = 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<void> {
const inFolderPath: string = `${buildFolderPath}/temp/etc`;
const outFolderPath: string = `${buildFolderPath}/etc`;
await FileSystem.ensureFolderAsync(outFolderPath);
const inFolderPaths: AsyncIterable<string> = enumerateFolderPaths(inFolderPath, '');
const outFolderPaths: AsyncIterable<string> = enumerateFolderPaths(outFolderPath, '');
const outFolderPathsSet: Set<string> = new Set<string>();
for await (const outFolderPath of outFolderPaths) {
outFolderPathsSet.add(outFolderPath);
}
const nonMatchingFiles: string[] = [];
const nonMatchingFileErrorMessages: Map<string, string> = new Map<string, string>();
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 "<workingDirectory>" 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<string> {
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;
}
}
}

View file

@ -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"]
}
}

View file

@ -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:

View file

@ -0,0 +1,50 @@
/**
* OWNER: <your team name>
* PURPOSE: <what you use this profile for>
*/
{
"$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"
]
}

View file

@ -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
{