diff --git a/packages/repo-tools/src/commands/package-docs/Cache.test.ts b/packages/repo-tools/src/commands/package-docs/Cache.test.ts index 15205846f3..3fe52dcabd 100644 --- a/packages/repo-tools/src/commands/package-docs/Cache.test.ts +++ b/packages/repo-tools/src/commands/package-docs/Cache.test.ts @@ -16,22 +16,163 @@ import { Lockfile } from '@backstage/cli-node'; import { PackageDocsCache } from './Cache'; -import { join as joinPath } from 'path'; import { createMockDirectory, MockDirectory, } from '@backstage/backend-test-utils'; +import { readFile } from 'fs/promises'; +import { join as joinPath } from 'path'; + +jest.mock('crypto', () => { + const hash = { + update: jest.fn(), + digest: jest.fn().mockReturnValue('test'), + }; + return { + createHash: jest.fn().mockReturnValue(hash), + }; +}); describe('PackageDocsCache', () => { let testDir: MockDirectory; - beforeEach(async () => { + beforeAll(async () => { testDir = createMockDirectory(); }); + afterEach(async () => { + testDir.clear(); + }); it('should be able to parse cache files', async () => { - const cache = await PackageDocsCache.loadAsync( - joinPath(__dirname, 'test-cache'), - new Lockfile(), + testDir.addContent({ + '.cache': { + 'package-docs': { + test: { + 'cache.json': JSON.stringify({ + hash: 'test', + packageName: 'test', + restoreTo: 'test', + version: '1', + }), + }, + }, + }, + test: { + 'package.json': JSON.stringify({ + name: '@test/test', + }), + }, + }); + const lockfile = { + getDependencyTreeHash: () => 'test', + } as any as Lockfile; + const cache = await PackageDocsCache.loadAsync(testDir.path, lockfile); + expect(await cache.has('test')).toBe(true); + }); + + it('should be able to restore cache', async () => { + testDir.addContent({ + '.cache': { + 'package-docs': { + test: { + 'cache.json': JSON.stringify({ + hash: 'test', + packageName: 'test', + restoreTo: 'test', + version: '1', + }), + contents: { + 'src/index.ts': 'export const test = "test";', + }, + }, + }, + }, + test: { + 'package.json': JSON.stringify({ + name: '@test/test', + }), + }, + }); + const lockfile = { + getDependencyTreeHash: () => 'test', + } as any as Lockfile; + const cache = await PackageDocsCache.loadAsync(testDir.path, lockfile); + await cache.restore('test'); + expect( + await readFile(joinPath(testDir.path, 'test', 'src/index.ts'), 'utf-8'), + ).toBe('export const test = "test";'); + }); + + it('should be able to write cache', async () => { + testDir.addContent({ + '.cache': {}, + test: { + 'package.json': JSON.stringify({ + name: '@test/test', + }), + 'src/index.ts': 'export const test = "test";', + }, + }); + const lockfile = { + getDependencyTreeHash: () => 'test', + } as any as Lockfile; + const cache = await PackageDocsCache.loadAsync(testDir.path, lockfile); + await cache.write('test', joinPath(testDir.path, 'test')); + expect( + await readFile( + joinPath(testDir.path, '.cache', 'package-docs', 'test', 'cache.json'), + 'utf-8', + ), + ).toBe( + JSON.stringify({ + hash: 'test', + packageName: '@test/test', + restoreTo: 'test', + version: '1', + }), ); - cache.add('test', 'test'); + expect( + await readFile( + joinPath( + testDir.path, + '.cache', + 'package-docs', + 'test', + 'contents', + 'src/index.ts', + ), + 'utf-8', + ), + ).toBe('export const test = "test";'); + }); + + it.each([ + { + content: JSON.stringify({ + hash: 'test', + packageName: 'test', + restoreTo: 'test', + version: '2', + }), + }, + { + content: JSON.stringify({ + hash: 'test', + packageName: 1, + }), + }, + ])('should skip invalid cache files', async content => { + testDir.addContent({ + '.cache': {}, + test: { + 'package.json': JSON.stringify({ + name: '@test/test', + }), + }, + 'cache.json': content, + }); + const lockfile = { + getDependencyTreeHash: () => 'test', + } as any as Lockfile; + const cache = await PackageDocsCache.loadAsync(testDir.path, lockfile); + expect(await cache.has('test')).toBe(false); }); }); diff --git a/packages/repo-tools/src/commands/package-docs/Cache.ts b/packages/repo-tools/src/commands/package-docs/Cache.ts index 6b64b523e3..b8e22f65d6 100644 --- a/packages/repo-tools/src/commands/package-docs/Cache.ts +++ b/packages/repo-tools/src/commands/package-docs/Cache.ts @@ -18,12 +18,11 @@ import globby from 'globby'; import { dirname, join as joinPath, relative } from 'path'; import crypto from 'crypto'; import { Lockfile } from '@backstage/cli-node'; -import { paths as cliPaths } from '../../lib/paths'; import { mkdirp } from 'fs-extra'; import { z } from 'zod'; +import { CACHE_DIR, CACHE_FILE } from './constants'; const version = '1'; -const CACHE_FILE = 'cache.json'; interface CacheEntry { hash: string; @@ -46,11 +45,13 @@ export class PackageDocsCache { private readonly lockfile: Lockfile, // A map of package directory to cache entry. private readonly cache: Map, - private readonly cacheDir: string, + private readonly baseDirectory: string, ) { this.keyCache = new Map(); } - static async loadAsync(cacheDir: string, lockfile: Lockfile) { + static async loadAsync(baseDirectory: string, lockfile: Lockfile) { + const cacheDir = joinPath(baseDirectory, CACHE_DIR); + await mkdirp(cacheDir); const cacheFiles = await globby(`**/${CACHE_FILE}`, { cwd: cacheDir, }); @@ -61,23 +62,29 @@ export class PackageDocsCache { try { const cacheJson = JSON.parse(cache); const parsed = cacheEntrySchema.parse(cacheJson); + if (parsed.version !== version) { + console.warn( + `Skipping cache file ${file} due to version mismatch: ${parsed.version} !== ${version}`, + ); + continue; + } map.set(pkg, parsed); } catch (e) { console.error(`Skipping unparseable cache file ${file}: ${e}`); } } - return new PackageDocsCache(lockfile, map, cacheDir); + return new PackageDocsCache(lockfile, map, baseDirectory); } async directoryToName(directory: string) { const packageJson = await readFile( - joinPath(directory, 'package.json'), + joinPath(this.baseDirectory, directory, 'package.json'), 'utf-8', ); return JSON.parse(packageJson).name; } - async toKey(pkg: string) { + private async toKey(pkg: string) { if (this.keyCache.has(pkg)) { return this.keyCache.get(pkg)!; } @@ -93,7 +100,7 @@ export class PackageDocsCache { hash.update('\0'); for (const path of result.sort()) { - const absPath = cliPaths.resolveTargetRoot(pkg, path); + const absPath = joinPath(this.baseDirectory, pkg, path); const pathInPackage = joinPath(absPath, path); hash.update(pathInPackage); hash.update('\0'); @@ -122,16 +129,16 @@ export class PackageDocsCache { } const cacheEntry = this.cache.get(pkg); const restoreTo = cacheEntry!.restoreTo; - const cacheDir = joinPath(this.cacheDir, pkg); + const cacheDir = joinPath(this.baseDirectory, CACHE_DIR, pkg); const contentsDir = joinPath(cacheDir, 'contents'); - const targetDir = cliPaths.resolveTargetRoot(restoreTo); + const targetDir = joinPath(this.baseDirectory, restoreTo); await mkdirp(targetDir); await cp(contentsDir, targetDir, { recursive: true }); } async write(pkg: string, contentDirectory: string) { - const cacheDir = joinPath(this.cacheDir, pkg); + const cacheDir = joinPath(this.baseDirectory, CACHE_DIR, pkg); const contentsDir = joinPath(cacheDir, 'contents'); await mkdirp(contentsDir); const hashString = await this.toKey(pkg); @@ -139,7 +146,7 @@ export class PackageDocsCache { const cacheEntry: CacheEntry = { hash: hashString, packageName: await this.directoryToName(pkg), - restoreTo: relative(cliPaths.resolveTargetRoot(), contentDirectory), + restoreTo: relative(this.baseDirectory, contentDirectory), version, }; await writeFile(joinPath(cacheDir, CACHE_FILE), JSON.stringify(cacheEntry)); diff --git a/packages/repo-tools/src/commands/package-docs/command.ts b/packages/repo-tools/src/commands/package-docs/command.ts index 50f52c6b87..d02bcad396 100644 --- a/packages/repo-tools/src/commands/package-docs/command.ts +++ b/packages/repo-tools/src/commands/package-docs/command.ts @@ -58,8 +58,6 @@ const HIGHLIGHT_LANGUAGES = [ 'json', ]; -const CACHE_DIR = '.cache/package-docs'; - function getExports(packageJson: any) { if (packageJson.exports) { return Object.values(packageJson.exports).filter( @@ -120,9 +118,8 @@ export default async function packageDocs(paths: string[] = [], opts: any) { exclude: opts.exclude, }); - await mkdirp(CACHE_DIR); const cache = await PackageDocsCache.loadAsync( - CACHE_DIR, + cliPaths.resolveTargetRoot(), await Lockfile.load(cliPaths.resolveTargetRoot('yarn.lock')), ); diff --git a/packages/repo-tools/src/commands/package-docs/constants.ts b/packages/repo-tools/src/commands/package-docs/constants.ts new file mode 100644 index 0000000000..a8372afad8 --- /dev/null +++ b/packages/repo-tools/src/commands/package-docs/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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. + */ + +export const CACHE_FILE = 'cache.json'; +export const CACHE_DIR = '.cache/package-docs';