Merge branch 'master' into feat/26838

This commit is contained in:
Łukasz Jernaś
2024-10-07 20:02:54 +02:00
77 changed files with 804 additions and 110 deletions
+1
View File
@@ -92,6 +92,7 @@ export function isMonoRepo(): Promise<boolean>;
export class Lockfile {
createSimplifiedDependencyGraph(): Map<string, Set<string>>;
diff(otherLockfile: Lockfile): LockfileDiff;
getDependencyTreeHash(startName: string): string;
static load(path: string): Promise<Lockfile>;
static parse(content: string): Lockfile;
}
@@ -493,4 +493,95 @@ d@^1:
);
});
});
describe('getDependencyTreeHash', () => {
const content = `${MODERN_HEADER}
"a@npm:^1":
version: "1.0.0"
checksum: sha512-a-1
dependencies:
b: "^2"
"b@npm:2.0.x, b@npm:^2":
version: "2.0.0"
checksum: sha512-b-1
"b@npm:4":
version: "3.0.0"
checksum: sha512-b-2
"c@npm:^1":
version: "4.0.0"
checksum: sha512-c-1
`;
const lockfile = Lockfile.parse(content);
const hashA = lockfile.getDependencyTreeHash('a');
const hashB = lockfile.getDependencyTreeHash('b');
const hashC = lockfile.getDependencyTreeHash('c');
it('should generate stable dependency hashes', () => {
expect(hashA).toMatchInlineSnapshot(
`"2d1d4c1c577c291e815e87779c72fc78a78e56cc"`,
);
expect(hashB).toMatchInlineSnapshot(
`"7e46d0c7337540179b442c87a7c5555543798f15"`,
);
expect(hashC).toMatchInlineSnapshot(
`"e65103abd217954bad40e2f834b990a5e6fa4054"`,
);
});
it('should generate different hashes for different versions', () => {
const lockfileNewA = Lockfile.parse(content.replace('1.0.0', '1.0.1'));
expect(lockfileNewA.getDependencyTreeHash('a')).not.toBe(hashA);
expect(lockfileNewA.getDependencyTreeHash('b')).toBe(hashB);
expect(lockfileNewA.getDependencyTreeHash('c')).toBe(hashC);
const lockfileNewB1 = Lockfile.parse(content.replace('2.0.0', '2.0.1'));
expect(lockfileNewB1.getDependencyTreeHash('a')).not.toBe(hashA);
expect(lockfileNewB1.getDependencyTreeHash('b')).not.toBe(hashB);
expect(lockfileNewB1.getDependencyTreeHash('c')).toBe(hashC);
const lockfileNewB2 = Lockfile.parse(content.replace('3.0.0', '3.0.1'));
expect(lockfileNewB2.getDependencyTreeHash('a')).not.toBe(hashA);
expect(lockfileNewB2.getDependencyTreeHash('b')).not.toBe(hashB);
expect(lockfileNewB2.getDependencyTreeHash('c')).toBe(hashC);
const lockfileNewC = Lockfile.parse(content.replace('4.0.0', '4.0.1'));
expect(lockfileNewC.getDependencyTreeHash('a')).toBe(hashA);
expect(lockfileNewC.getDependencyTreeHash('b')).toBe(hashB);
expect(lockfileNewC.getDependencyTreeHash('c')).not.toBe(hashC);
});
it('should generate different hashes for different checksums', () => {
const lockfileNewA = Lockfile.parse(
content.replace('sha512-a-1', 'sha512-a-1-new'),
);
expect(lockfileNewA.getDependencyTreeHash('a')).not.toBe(hashA);
expect(lockfileNewA.getDependencyTreeHash('b')).toBe(hashB);
expect(lockfileNewA.getDependencyTreeHash('c')).toBe(hashC);
const lockfileNewB1 = Lockfile.parse(
content.replace('sha512-b-1', 'sha512-b-1-new'),
);
expect(lockfileNewB1.getDependencyTreeHash('a')).not.toBe(hashA);
expect(lockfileNewB1.getDependencyTreeHash('b')).not.toBe(hashB);
expect(lockfileNewB1.getDependencyTreeHash('c')).toBe(hashC);
const lockfileNewB2 = Lockfile.parse(
content.replace('sha512-b-2', 'sha512-b-2-new'),
);
expect(lockfileNewB2.getDependencyTreeHash('a')).not.toBe(hashA);
expect(lockfileNewB2.getDependencyTreeHash('b')).not.toBe(hashB);
expect(lockfileNewB2.getDependencyTreeHash('c')).toBe(hashC);
const lockfileNewC = Lockfile.parse(
content.replace('sha512-c-1', 'sha512-c-1-new'),
);
expect(lockfileNewC.getDependencyTreeHash('a')).toBe(hashA);
expect(lockfileNewC.getDependencyTreeHash('b')).toBe(hashB);
expect(lockfileNewC.getDependencyTreeHash('c')).not.toBe(hashC);
});
});
});
@@ -15,6 +15,7 @@
*/
import { parseSyml } from '@yarnpkg/parsers';
import crypto from 'node:crypto';
import fs from 'fs-extra';
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
@@ -215,4 +216,63 @@ export class Lockfile {
return diff;
}
/**
* Generates a sha1 hex hash of the dependency graph for a package.
*/
getDependencyTreeHash(startName: string): string {
if (!this.packages.has(startName)) {
throw new Error(`Package '${startName}' not found in lockfile`);
}
const hash = crypto.createHash('sha1');
const queue = [startName];
const seen = new Set<string>();
while (queue.length > 0) {
const name = queue.pop()!;
if (seen.has(name)) {
continue;
}
seen.add(name);
const entries = this.packages.get(name);
if (!entries) {
continue; // In case of missing optional peer dependencies
}
hash.update(`pkg:${name}`);
hash.update('\0');
// TODO(Rugvip): This uses the same simplified lookup as createSimplifiedDependencyGraph()
// we could match version queries to make the resulting tree a bit smaller.
const deps = new Array<string>();
for (const entry of entries) {
// We're not being particular about stable ordering here. If the lockfile ordering changes, so will likely hash.
hash.update(entry.version);
const data = this.data[entry.dataKey];
if (!data) {
continue;
}
const checksum = data.checksum || data.integrity;
if (checksum) {
hash.update('#');
hash.update(checksum);
}
hash.update(' ');
deps.push(...Object.keys(data.dependencies ?? {}));
deps.push(...Object.keys(data.peerDependencies ?? {}));
}
queue.push(...new Set(deps));
}
return hash.digest('hex');
}
}
+2
View File
@@ -446,6 +446,8 @@ Usage: backstage-cli repo lint [options]
Options:
--format <format>
--since <ref>
--successCache
--successCacheDir <path>
--fix
-h, --help
```
+8
View File
@@ -61,6 +61,14 @@ export function registerRepoCommand(program: Command) {
'--since <ref>',
'Only lint packages that changed since the specified ref',
)
.option(
'--successCache',
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
)
.option(
'--successCacheDir <path>',
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
)
.option('--fix', 'Attempt to automatically fix violations')
.action(lazy(() => import('./repo/lint').then(m => m.command)));
+133 -10
View File
@@ -16,8 +16,14 @@
import chalk from 'chalk';
import { Command, OptionValues } from 'commander';
import { relative as relativePath } from 'path';
import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node';
import fs from 'fs-extra';
import { createHash } from 'crypto';
import { relative as relativePath, resolve as resolvePath } from 'path';
import {
PackageGraph,
BackstagePackageJson,
Lockfile,
} from '@backstage/cli-node';
import { paths } from '../../lib/paths';
import { runWorkerQueueThreads } from '../../lib/parallel';
import { createScriptOptionsParser } from './optionsParser';
@@ -30,9 +36,43 @@ function depCount(pkg: BackstagePackageJson) {
return deps + devDeps;
}
const CACHE_FILE_NAME = 'lint-cache.json';
type Cache = string[];
async function readCache(dir: string): Promise<Cache | undefined> {
try {
const data = await fs.readJson(resolvePath(dir, CACHE_FILE_NAME));
if (!Array.isArray(data)) {
return undefined;
}
if (data.some(x => typeof x !== 'string')) {
return undefined;
}
return data as Cache;
} catch {
return undefined;
}
}
async function writeCache(dir: string, cache: Cache) {
await fs.mkdirp(dir);
await fs.writeJson(resolvePath(dir, CACHE_FILE_NAME), cache, { spaces: 2 });
}
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
const cacheDir = resolvePath(
opts.successCacheDir ?? 'node_modules/.cache/backstage-cli',
);
const cacheContext = opts.successCache
? {
cache: await readCache(cacheDir),
lockfile: await Lockfile.load(paths.resolveTargetRoot('yarn.lock')),
}
: undefined;
if (opts.since) {
const graph = PackageGraph.fromPackages(packages);
packages = await graph.listChangedPackages({
@@ -57,26 +97,66 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']);
const items = await Promise.all(
packages.map(async pkg => {
const lintOptions = parseLintScript(pkg.packageJson.scripts?.lint);
const base = {
fullDir: pkg.dir,
relativeDir: relativePath(paths.targetRoot, pkg.dir),
lintOptions,
parentHash: undefined,
};
if (!cacheContext) {
return base;
}
const hash = createHash('sha1');
hash.update(
cacheContext.lockfile.getDependencyTreeHash(pkg.packageJson.name),
);
hash.update('\0');
hash.update(JSON.stringify(lintOptions));
hash.update('\0');
hash.update(process.version); // Node.js version
hash.update('\0');
hash.update('v1'); // The version of this implementation
return {
...base,
parentHash: hash.digest('hex'),
};
}),
);
const resultsList = await runWorkerQueueThreads({
items: packages.map(pkg => ({
fullDir: pkg.dir,
relativeDir: relativePath(paths.targetRoot, pkg.dir),
lintOptions: parseLintScript(pkg.packageJson.scripts?.lint),
})),
items,
workerData: {
fix: Boolean(opts.fix),
format: opts.format as string | undefined,
shouldCache: Boolean(cacheContext),
successCache: cacheContext?.cache,
},
workerFactory: async ({ fix, format }) => {
workerFactory: async ({ fix, format, shouldCache, successCache }) => {
const { ESLint } = require('eslint') as typeof import('eslint');
const crypto = require('crypto') as typeof import('crypto');
const recursiveReadDir =
require('recursive-readdir') as typeof import('recursive-readdir');
const { readFile } =
require('fs/promises') as typeof import('fs/promises');
const { relative: workerRelativePath } =
require('path') as typeof import('path');
return async ({
fullDir,
relativeDir,
lintOptions,
parentHash,
}): Promise<{
relativeDir: string;
resultText: string;
sha?: string;
resultText?: string;
failed: boolean;
}> => {
// Bit of a hack to make file resolutions happen from the correct directory
@@ -89,6 +169,35 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
fix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
let sha: string | undefined = undefined;
if (shouldCache) {
const result = await recursiveReadDir(fullDir);
const hash = crypto.createHash('sha1');
hash.update(parentHash!);
hash.update('\0');
for (const path of result.sort()) {
if (await eslint.isPathIgnored(path)) {
continue;
}
hash.update(workerRelativePath(fullDir, path));
hash.update('\0');
hash.update(await readFile(path));
hash.update('\0');
hash.update(
JSON.stringify(await eslint.calculateConfigForFile(path)),
);
hash.update('\0');
}
sha = await hash.digest('hex');
if (successCache?.includes(sha)) {
console.log(`Skipped ${relativeDir} due to cache hit`);
return { relativeDir, sha, failed: false };
}
}
const formatter = await eslint.loadFormatter(format);
const results = await eslint.lintFiles(['.']);
@@ -112,13 +221,21 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
relativeDir,
resultText,
failed,
sha,
};
};
},
});
const outputSuccessCache = [];
let failed = false;
for (const { relativeDir, resultText, failed: runFailed } of resultsList) {
for (const {
relativeDir,
resultText,
failed: runFailed,
sha,
} of resultsList) {
if (runFailed) {
console.log(chalk.red(`Lint failed in ${relativeDir}`));
failed = true;
@@ -129,9 +246,15 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
console.log();
console.log(resultText.trimStart());
}
} else if (sha) {
outputSuccessCache.push(sha);
}
}
if (cacheContext) {
await writeCache(cacheDir, outputSuccessCache);
}
if (failed) {
process.exit(1);
}
+2
View File
@@ -123,6 +123,8 @@ export async function makeRollupConfigs(
format: 'commonjs',
interop: 'compat',
sourcemap: true,
preserveModules: true,
preserveModulesRoot: `${targetDir}/src`,
exports: 'named',
});
}
@@ -90,7 +90,7 @@ describe('backendModule factory', () => {
`availability plugins${sep}test-backend-module-tester-two`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -89,7 +89,7 @@ describe('backendPlugin factory', () => {
`availability plugins${sep}test-backend`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating index.ts.hbs',
'templating package.json.hbs',
@@ -91,7 +91,7 @@ describe('frontendPlugin factory', () => {
`availability plugins${sep}test`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.tsx.hbs',
@@ -75,7 +75,7 @@ describe('nodeLibraryPackage factory', () => {
`availability ${joinPath('packages', expectedNodeLibraryPackageName)}`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -73,7 +73,7 @@ describe('pluginCommon factory', () => {
`availability plugins${sep}test-common`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -73,7 +73,7 @@ describe('pluginNode factory', () => {
`availability plugins${sep}test-node`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -73,7 +73,7 @@ describe('pluginWeb factory', () => {
`availability plugins${sep}test-react`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -89,7 +89,7 @@ describe('scaffolderModule factory', () => {
`availability plugins${sep}scaffolder-backend-module-test`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -75,7 +75,7 @@ describe('webLibraryPackage factory', () => {
`availability ${joinPath('packages', expectedwebLibraryPackageName)}`,
'creating temp dir',
'Executing Template:',
'copying .eslintrc.js',
'templating .eslintrc.js.hbs',
'templating README.md.hbs',
'templating package.json.hbs',
'templating index.ts.hbs',
@@ -50,6 +50,7 @@
},
"peerDependencies": {
"@testing-library/react": "^16.0.0",
"@types/jest": "*",
"@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
@@ -3,11 +3,13 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="jest" />
/// <reference types="react" />
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppNodeInstance } from '@backstage/frontend-plugin-api';
import { ErrorWithContext } from '@backstage/test-utils';
@@ -32,6 +34,15 @@ import { TestApiProviderProps } from '@backstage/test-utils';
import { TestApiRegistry } from '@backstage/test-utils';
import { withLogCollector } from '@backstage/test-utils';
// @public
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
// @public (undocumented)
export function createExtensionTester<T extends ExtensionDefinitionParameters>(
subject: ExtensionDefinition<T>,
@@ -0,0 +1,32 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiFactory } from '@backstage/frontend-plugin-api';
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
@@ -26,4 +26,5 @@ export {
type MockStorageBucket,
} from '@backstage/test-utils';
export { type ApiMock } from './ApiMock';
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';