mirror of
https://github.com/tiktok/sparo.git
synced 2024-11-27 09:36:04 -05:00
feat: build test for a real repo
This commit is contained in:
parent
7cf0c1443f
commit
ab7cefefca
27 changed files with 892 additions and 182 deletions
|
@ -57,7 +57,9 @@ an empty directory.`);
|
||||||
terminal.writeDebugLine('full clone start...');
|
terminal.writeDebugLine('full clone start...');
|
||||||
const cloneArgs: string[] = ['clone', repository, directory];
|
const cloneArgs: string[] = ['clone', repository, directory];
|
||||||
const result: child_process.SpawnSyncReturns<string> = this._gitService.executeGitCommand({
|
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) {
|
if (result?.status) {
|
||||||
throw new Error(`git clone failed with exit code ${result.status}`);
|
throw new Error(`git clone failed with exit code ${result.status}`);
|
||||||
|
@ -79,7 +81,9 @@ an empty directory.`);
|
||||||
directory
|
directory
|
||||||
];
|
];
|
||||||
const result: child_process.SpawnSyncReturns<string> = this._gitService.executeGitCommand({
|
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) {
|
if (result?.status) {
|
||||||
throw new Error(`git clone failed with exit code ${result.status}`);
|
throw new Error(`git clone failed with exit code ${result.status}`);
|
||||||
|
@ -101,7 +105,9 @@ an empty directory.`);
|
||||||
directory
|
directory
|
||||||
];
|
];
|
||||||
const result: child_process.SpawnSyncReturns<string> = this._gitService.executeGitCommand({
|
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) {
|
if (result?.status) {
|
||||||
throw new Error(`git clone failed with exit code ${result.status}`);
|
throw new Error(`git clone failed with exit code ${result.status}`);
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rushstack/node-core-library": "~3.64.2",
|
"@rushstack/node-core-library": "~3.64.2",
|
||||||
"sparo": "workspace:*",
|
"build-test-utilities": "workspace:*",
|
||||||
"jest-diff": "~29.7.0"
|
"sparo": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/heft": "0.64.3",
|
"@rushstack/heft": "0.64.3",
|
||||||
|
|
|
@ -1,24 +1,10 @@
|
||||||
import * as path from 'path';
|
import {
|
||||||
import { Async, Executable, FileSystem, type FolderItem, Text } from '@rushstack/node-core-library';
|
ICommandDefinition,
|
||||||
import { diff } from 'jest-diff';
|
executeCommandsAndCollectOutputs,
|
||||||
import type { SpawnSyncReturns } from 'child_process';
|
updateOrCompareOutputs
|
||||||
|
} from 'build-test-utilities';
|
||||||
import type { IRunScriptOptions } from '@rushstack/heft';
|
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.
|
* 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 }
|
heftConfiguration: { buildFolderPath }
|
||||||
} = runScriptOptions;
|
} = 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',
|
name: 'top-level-help',
|
||||||
args: ['--help']
|
args: ['--help']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'sparo-command',
|
||||||
name: 'clone-help',
|
name: 'clone-help',
|
||||||
args: ['clone', '--help']
|
args: ['clone', '--help']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'sparo-command',
|
||||||
name: 'checkout-help',
|
name: 'checkout-help',
|
||||||
args: ['checkout', '--help']
|
args: ['checkout', '--help']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'sparo-command',
|
||||||
name: 'top-level-nonexistent-command',
|
name: 'top-level-nonexistent-command',
|
||||||
args: ['nonexistent-command']
|
args: ['nonexistent-command']
|
||||||
}
|
}
|
||||||
// FIXME: This is currently broken -- it simply ignores the unrecognized parameter
|
// FIXME: This is currently broken -- it simply ignores the unrecognized parameter
|
||||||
// {
|
// {
|
||||||
|
// kind: 'sparo-command',
|
||||||
// name: 'checkout-nonexistent-parameter',
|
// name: 'checkout-nonexistent-parameter',
|
||||||
// args: ['checkout', '--nonexistent-parameter']
|
// args: ['checkout', '--nonexistent-parameter']
|
||||||
// }
|
// }
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
await executeCommandsAndCollectOutputs({
|
||||||
* Run each scenario and generate outputs
|
buildFolderPath,
|
||||||
*/
|
commandDefinitions
|
||||||
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'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
await updateOrCompareOutputs({
|
||||||
throw new Error(
|
buildFolderPath,
|
||||||
`Failed to run "sparo ${args.join(' ')}" with exit code ${result.status}\n${result.stderr}`
|
logger,
|
||||||
);
|
production
|
||||||
}
|
});
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
19
build-tests/sparo-real-repo-test/.eslintrc.js
Normal file
19
build-tests/sparo-real-repo-test/.eslintrc.js
Normal 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: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
21
build-tests/sparo-real-repo-test/LICENSE
Normal file
21
build-tests/sparo-real-repo-test/LICENSE
Normal 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.
|
11
build-tests/sparo-real-repo-test/README.md
Normal file
11
build-tests/sparo-real-repo-test/README.md
Normal 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.
|
24
build-tests/sparo-real-repo-test/config/heft.json
Normal file
24
build-tests/sparo-real-repo-test/config/heft.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
build-tests/sparo-real-repo-test/config/rig.json
Normal file
5
build-tests/sparo-real-repo-test/config/rig.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||||
|
|
||||||
|
"rigPackageName": "@rushstack/heft-node-rig"
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
Running "sparo checkout --profile my-team":
|
||||||
|
|
||||||
|
[1mSparo accelerator for Git __VERSION__ -[22m[36m https://tiktok.github.io/sparo/[39m
|
||||||
|
Node.js version is __VERSION__ (LTS)
|
||||||
|
Git version is __VERSION__
|
||||||
|
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit checkout[22m [90m]-------------------------------------------------------------[39m
|
||||||
|
Your branch is up to date with 'origin/main'.
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Syncing checkout with the Sparo profile: my-team
|
||||||
|
|
||||||
|
Checking out and updating core files...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out skeleton...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Sparse checkout completed in __DURATION__
|
29
build-tests/sparo-real-repo-test/etc/checkout-profile.txt
Normal file
29
build-tests/sparo-real-repo-test/etc/checkout-profile.txt
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
Running "sparo checkout --profile my-team":
|
||||||
|
|
||||||
|
[1mSparo accelerator for Git __VERSION__ -[22m[36m https://tiktok.github.io/sparo/[39m
|
||||||
|
Node.js version is __VERSION__ (LTS)
|
||||||
|
Git version is __VERSION__
|
||||||
|
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit checkout[22m [90m]-------------------------------------------------------------[39m
|
||||||
|
Your branch is up to date with 'origin/main'.
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Syncing checkout with the Sparo profile: my-team
|
||||||
|
|
||||||
|
Checking out and updating core files...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out skeleton...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out __FOLDER_COUNT__ folders...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Sparse checkout completed in __DURATION__
|
40
build-tests/sparo-real-repo-test/etc/clone.txt
Normal file
40
build-tests/sparo-real-repo-test/etc/clone.txt
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
Running "sparo clone git@github.com:Azure/azure-sdk-for-js.git":
|
||||||
|
|
||||||
|
[1mSparo accelerator for Git __VERSION__ -[22m[36m https://tiktok.github.io/sparo/[39m
|
||||||
|
Node.js version is __VERSION__ (LTS)
|
||||||
|
Git version is __VERSION__
|
||||||
|
|
||||||
|
Initializing working directory...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit clone[22m [90m]----------------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out and updating core files...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out skeleton...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Applying recommended configuration...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit maintenance[22m [90m]----------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
[32mSuccess: Working directory "azure-sdk-for-js" was prepared in __DURATION__.[39m
|
||||||
|
|
||||||
|
Don't forget to change your shell path:
|
||||||
|
[36mcd azure-sdk-for-js[39m
|
||||||
|
|
||||||
|
Your next step is to choose a Sparo profile for checkout.
|
||||||
|
To see available profiles in this repo:
|
||||||
|
[36msparo list-profiles[39m
|
||||||
|
To checkout and set profile:
|
||||||
|
[36msparo checkout --profile <profile_name>[39m
|
||||||
|
To checkout and add profile:
|
||||||
|
[36msparo checkout --add-profile <profile_name>[39m
|
||||||
|
To create a new profile:
|
||||||
|
[36msparo init-profile --profile <profile_name>[39m
|
11
build-tests/sparo-real-repo-test/etc/init-profile.txt
Normal file
11
build-tests/sparo-real-repo-test/etc/init-profile.txt
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
Running "sparo init-profile --profile my-team":
|
||||||
|
|
||||||
|
[1mSparo accelerator for Git __VERSION__ -[22m[36m https://tiktok.github.io/sparo/[39m
|
||||||
|
Node.js version is __VERSION__ (LTS)
|
||||||
|
Git version is __VERSION__
|
||||||
|
|
||||||
|
[32mSuccessfully initialized the "my-team" profile.[39m
|
||||||
|
|
||||||
|
Next step: Open this file in your editor and configure the project selectors:
|
||||||
|
|
||||||
|
[36m__WORKING_DIRECTORY__/common/sparo-profiles/my-team.json[39m
|
|
@ -0,0 +1,20 @@
|
||||||
|
Running "sparo list-profiles --project @azure/core-auth":
|
||||||
|
|
||||||
|
[1mSparo accelerator for Git __VERSION__ -[22m[36m https://tiktok.github.io/sparo/[39m
|
||||||
|
Node.js version is __VERSION__ (LTS)
|
||||||
|
Git version is __VERSION__
|
||||||
|
|
||||||
|
Listing profiles...
|
||||||
|
|
||||||
|
Checking out and updating core files...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out skeleton...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
@azure/core-auth was included in the below profiles:
|
||||||
|
my-team
|
20
build-tests/sparo-real-repo-test/etc/list-profiles.txt
Normal file
20
build-tests/sparo-real-repo-test/etc/list-profiles.txt
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
Running "sparo list-profiles":
|
||||||
|
|
||||||
|
[1mSparo accelerator for Git __VERSION__ -[22m[36m https://tiktok.github.io/sparo/[39m
|
||||||
|
Node.js version is __VERSION__ (LTS)
|
||||||
|
Git version is __VERSION__
|
||||||
|
|
||||||
|
Listing profiles...
|
||||||
|
|
||||||
|
Checking out and updating core files...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
Checking out skeleton...
|
||||||
|
|
||||||
|
[90m--[[39m [1mgit sparse-checkout[22m [90m]------------------------------------------------------[39m
|
||||||
|
[90m-------------------------------------------------------------------------------[39m
|
||||||
|
|
||||||
|
All available profiles:
|
||||||
|
my-team
|
25
build-tests/sparo-real-repo-test/package.json
Normal file
25
build-tests/sparo-real-repo-test/package.json
Normal 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"
|
||||||
|
}
|
113
build-tests/sparo-real-repo-test/src/start-test.ts
Normal file
113
build-tests/sparo-real-repo-test/src/start-test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
8
build-tests/sparo-real-repo-test/tsconfig.json
Normal file
8
build-tests/sparo-real-repo-test/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
19
build-tests/test-utilities/.eslintrc.js
Normal file
19
build-tests/test-utilities/.eslintrc.js
Normal 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: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
21
build-tests/test-utilities/LICENSE
Normal file
21
build-tests/test-utilities/LICENSE
Normal 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.
|
11
build-tests/test-utilities/README.md
Normal file
11
build-tests/test-utilities/README.md
Normal 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.
|
5
build-tests/test-utilities/config/rig.json
Normal file
5
build-tests/test-utilities/config/rig.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||||
|
|
||||||
|
"rigPackageName": "@rushstack/heft-node-rig"
|
||||||
|
}
|
26
build-tests/test-utilities/package.json
Normal file
26
build-tests/test-utilities/package.json
Normal 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"
|
||||||
|
}
|
281
build-tests/test-utilities/src/index.ts
Normal file
281
build-tests/test-utilities/src/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
build-tests/test-utilities/tsconfig.json
Normal file
8
build-tests/test-utilities/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -154,9 +154,9 @@ importers:
|
||||||
'@rushstack/node-core-library':
|
'@rushstack/node-core-library':
|
||||||
specifier: ~3.64.2
|
specifier: ~3.64.2
|
||||||
version: 3.64.2(@types/node@20.11.16)
|
version: 3.64.2(@types/node@20.11.16)
|
||||||
jest-diff:
|
build-test-utilities:
|
||||||
specifier: ~29.7.0
|
specifier: workspace:*
|
||||||
version: 29.7.0
|
version: link:../test-utilities
|
||||||
sparo:
|
sparo:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../apps/sparo
|
version: link:../../apps/sparo
|
||||||
|
@ -180,6 +180,65 @@ importers:
|
||||||
specifier: ~5.3.3
|
specifier: ~5.3.3
|
||||||
version: 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:
|
packages:
|
||||||
|
|
||||||
/@aashutoshrathi/word-wrap@1.2.6:
|
/@aashutoshrathi/word-wrap@1.2.6:
|
||||||
|
|
50
common/sparo-profiles/my-team.json
Normal file
50
common/sparo-profiles/my-team.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
|
@ -440,6 +440,14 @@
|
||||||
"packageName": "sparo-output-test",
|
"packageName": "sparo-output-test",
|
||||||
"projectFolder": "build-tests/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
|
// Sparo
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue