diff --git a/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap b/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap index 90368b081..e9f9d47fd 100644 --- a/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap @@ -36,6 +36,14 @@ Global Options: -p, --onlyPlugins List of plugins to run. If not set all plugins are run. [array] [default: []] +Cache Options: + --cache Cache runner outputs (both read and write) + [boolean] + --cache.read Read runner-output.json from file system + [boolean] + --cache.write Write runner-output.json to file system + [boolean] + Persist Options: --persist.outputDir Directory for the produced reports [string] diff --git a/e2e/cli-e2e/tests/collect.e2e.test.ts b/e2e/cli-e2e/tests/collect.e2e.test.ts index f1f76ddfc..39f341885 100644 --- a/e2e/cli-e2e/tests/collect.e2e.test.ts +++ b/e2e/cli-e2e/tests/collect.e2e.test.ts @@ -7,7 +7,13 @@ import { TEST_OUTPUT_DIR, teardownTestFolder, } from '@code-pushup/test-utils'; -import { executeProcess, fileExists, readTextFile } from '@code-pushup/utils'; +import { + executeProcess, + fileExists, + readJsonFile, + readTextFile, +} from '@code-pushup/utils'; +import { dummyPluginSlug } from '../mocks/fixtures/dummy-setup/dummy.plugin'; describe('CLI collect', () => { const dummyPluginTitle = 'Dummy Plugin'; @@ -61,6 +67,28 @@ describe('CLI collect', () => { expect(md).toContain(dummyAuditTitle); }); + it('should write runner outputs if --cache is given', async () => { + const { code } = await executeProcess({ + command: 'npx', + args: ['@code-pushup/cli', '--no-progress', 'collect', '--cache'], + cwd: dummyDir, + }); + + expect(code).toBe(0); + + await expect( + readJsonFile( + path.join(dummyOutputDir, dummyPluginSlug, 'runner-output.json'), + ), + ).resolves.toStrictEqual([ + { + slug: 'dummy-audit', + score: 0.3, + value: 3, + }, + ]); + }); + it('should not create reports if --persist.skipReports is given', async () => { const { code } = await executeProcess({ command: 'npx', diff --git a/packages/cli/README.md b/packages/cli/README.md index 7bb2e8f90..294599b49 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -207,18 +207,40 @@ Each example is fully tested to demonstrate best practices for plugin testing as ### Common Command Options -| Option | Type | Default | Description | -| --------------------------- | -------------------- | -------- | --------------------------------------------------------------------------- | -| **`--persist.outputDir`** | `string` | n/a | Directory for the produced reports. | -| **`--persist.filename`** | `string` | `report` | Filename for the produced reports without extension. | -| **`--persist.format`** | `('json' \| 'md')[]` | `json` | Format(s) of the report file. | -| **`--persist.skipReports`** | `boolean` | `false` | Skip generating report files. (useful in combination with caching) | -| **`--upload.organization`** | `string` | n/a | Organization slug from portal. | -| **`--upload.project`** | `string` | n/a | Project slug from portal. | -| **`--upload.server`** | `string` | n/a | URL to your portal server. | -| **`--upload.apiKey`** | `string` | n/a | API key for the portal server. | -| **`--onlyPlugins`** | `string[]` | `[]` | Only run the specified plugins. Applicable to all commands except `upload`. | -| **`--skipPlugins`** | `string[]` | `[]` | Skip the specified plugins. Applicable to all commands except `upload`. | +#### Global Options + +| Option | Type | Default | Description | +| ---------------------- | ---------- | ------- | ------------------------------------------------------------------------------ | +| **`--onlyPlugins`** | `string[]` | `[]` | Only run the specified plugins. Applicable to all commands except `upload`. | +| **`--skipPlugins`** | `string[]` | `[]` | Skip the specified plugins. Applicable to all commands except `upload`. | +| **`--onlyCategories`** | `string[]` | `[]` | Only run the specified categories. Applicable to all commands except `upload`. | +| **`--skipCategories`** | `string[]` | `[]` | Skip the specified categories. Applicable to all commands except `upload`. | + +#### Cache Options + +| Option | Type | Default | Description | +| ------------------- | --------- | ------- | --------------------------------------------------------------- | +| **`--cache`** | `boolean` | `false` | Cache runner outputs (both read and write). | +| **`--cache.read`** | `boolean` | `false` | If plugin audit outputs should be read from file system cache. | +| **`--cache.write`** | `boolean` | `false` | If plugin audit outputs should be written to file system cache. | + +#### Persist Options + +| Option | Type | Default | Description | +| --------------------------- | -------------------- | -------- | ------------------------------------------------------------------ | +| **`--persist.outputDir`** | `string` | n/a | Directory for the produced reports. | +| **`--persist.filename`** | `string` | `report` | Filename for the produced reports without extension. | +| **`--persist.format`** | `('json' \| 'md')[]` | `json` | Format(s) of the report file. | +| **`--persist.skipReports`** | `boolean` | `false` | Skip generating report files. (useful in combination with caching) | + +#### Upload Options + +| Option | Type | Default | Description | +| --------------------------- | -------- | ------- | ------------------------------ | +| **`--upload.organization`** | `string` | n/a | Organization slug from portal. | +| **`--upload.project`** | `string` | n/a | Project slug from portal. | +| **`--upload.server`** | `string` | n/a | URL to your portal server. | +| **`--upload.apiKey`** | `string` | n/a | API key for the portal server. | > [!NOTE] > All common options, except `--onlyPlugins` and `--skipPlugins`, can be specified in the configuration file as well. @@ -327,3 +349,12 @@ In addition to the [Common Command Options](#common-command-options), the follow | Option | Required | Type | Description | | ------------- | :------: | ---------- | --------------------------------- | | **`--files`** | yes | `string[]` | List of `report-diff.json` paths. | + +## Caching + +The CLI supports caching to speed up subsequent runs and is compatible with Nx and Turborepo. + +Depending on your strategy, you can cache the generated reports files or plugin runner output. +For fine-grained caching, we suggest caching plugin runner output. + +The detailed example for [Nx caching](./docs/nx-caching.md) and [Turborepo caching](./docs/turbo-caching.md) is available in the docs. diff --git a/packages/cli/docs/nx-caching.md b/packages/cli/docs/nx-caching.md new file mode 100644 index 000000000..ce3f95f52 --- /dev/null +++ b/packages/cli/docs/nx-caching.md @@ -0,0 +1,106 @@ +# Caching Example Nx + +To cache plugin runner output, you can use the `--cache.write` and `--cache.read` options in combination with `--onlyPlugins` and `--persist.skipReports` command options. + +## `{projectRoot}/code-pushup.config.ts` + +```ts +import coveragePlugin from '@code-pushup/coverage-plugin'; +import jsPackagesPlugin from '@code-pushup/js-packages-plugin'; +import type { CoreConfig } from '@code-pushup/models'; + +export default { + plugins: [ + await coveragePlugin({ + reports: ['coverage/lcov.info'], + }), + await jsPackagesPlugin(), + ], + upload: { + server: 'https://api.code-pushup.example.com/graphql', + organization: 'my-org', + project: 'lib-a', + apiKey: process.env.CP_API_KEY, + }, +} satisfies CoreConfig; +``` + +## `{projectRoot}/project.json` + +```json +{ + "name": "lib-a", + "targets": { + "int-test": { + "cache": true, + "outputs": ["{options.coverage.reportsDirectory}"], + "executor": "@nx/vite:test", + "options": { + "configFile": "packages/lib-a/vitest.int.config.ts", + "coverage.reportsDirectory": "{projectRoot}/coverage/int-test" + } + }, + "unit-test": { + "cache": true, + "outputs": ["{options.coverage.reportsDirectory}"], + "executor": "@nx/vite:test", + "options": { + "configFile": "packages/lib-a/vitest.unit.config.ts", + "coverage.reportsDirectory": "{projectRoot}/coverage/unit-test" + } + }, + "code-pushup-coverage": { + "cache": true, + "outputs": ["{projectRoot}/.code-pushup/coverage"], + "executor": "nx:run-commands", + "options": { + "command": "npx @code-pushup/cli collect", + "args": ["--config={projectRoot}/code-pushup.config.ts", "--cache.write=true", "--persist.skipReports=true", "--persist.outputDir={projectRoot}/.code-pushup", "--upload.project={projectName}"] + }, + "dependsOn": ["unit-test", "int-test"] + }, + "code-pushup": { + "cache": true, + "outputs": ["{projectRoot}/.code-pushup"], + "executor": "nx:run-commands", + "options": { + "command": "npx @code-pushup/cli", + "args": ["--config={projectRoot}/code-pushup.config.ts", "--cache.read=true", "--persist.outputDir={projectRoot}/.code-pushup", "--upload.project={projectName}"] + }, + "dependsOn": ["code-pushup-coverage"] + } + } +} +``` + +## Nx Task Graph + +This configuration creates the following task dependency graph: + +**Legend:** + +- 🐳 = Cached target + +```mermaid +graph TD + A[lib-a:code-pushup 🐳] --> B[lib-a:code-pushup-coverage 🐳] + B --> C[lib-a:unit-test 🐳] + B --> D[lib-a:int-test 🐳] +``` + +## Command Line Example + +```bash +# Run all affected project plugins `coverage` and cache the output if configured +nx affected --target=code-pushup-coverage + +# Run all affected projects with plugins `coverage` and `js-packages` and upload the report to the portal +nx affected --target=code-pushup +``` + +This approach has the following benefits: + +1. **Parallel Execution**: Plugins can run in parallel +2. **Fine-grained Caching**: Code level cache invalidation enables usage of [affected](https://nx.dev/recipes/affected-tasks) command +3. **Dependency Management**: Leverage Nx task dependencies and its caching strategy +4. **Clear Separation**: Each plugin has its own target for better debugging and maintainability diff --git a/packages/cli/docs/turbo-caching.md b/packages/cli/docs/turbo-caching.md new file mode 100644 index 000000000..9c03c3071 --- /dev/null +++ b/packages/cli/docs/turbo-caching.md @@ -0,0 +1,98 @@ +# Caching Example Turborepo + +To cache plugin runner output with Turborepo, wire Code Pushup into your turbo.json pipeline and pass Code Pushup flags (`--cache.write`, `--cache.read`, `--onlyPlugins`, `--persist.skipReports`) through task scripts. Turborepo will cache task outputs declared in outputs, and you can target affected packages with `--filter=[origin/main]`. + +## `{projectRoot}/code-pushup.config.ts` + +```ts +import coveragePlugin from '@code-pushup/coverage-plugin'; +import jsPackagesPlugin from '@code-pushup/js-packages-plugin'; +import type { CoreConfig } from '@code-pushup/models'; + +export default { + plugins: [ + await coveragePlugin({ + reports: ['coverage/lcov.info'], + }), + await jsPackagesPlugin(), + ], + upload: { + server: 'https://api.code-pushup.example.com/graphql', + organization: 'my-org', + project: 'lib-a', + apiKey: process.env.CP_API_KEY, + }, +} satisfies CoreConfig; +``` + +## Root `turbo.json` + +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "unit-test": { + "outputs": ["coverage/unit-test/**"] + }, + "int-test": { + "outputs": ["coverage/int-test/**"] + }, + "code-pushup-coverage": { + "dependsOn": ["unit-test", "int-test"], + "outputs": [".code-pushup/coverage/**"] + }, + "code-pushup": { + "dependsOn": ["code-pushup-coverage"], + "outputs": [".code-pushup/**"] + } + } +} +``` + +## `packages/lib-a/package.json` + +```json +{ + "name": "lib-a", + "scripts": { + "unit-test": "vitest --config packages/lib-a/vitest.unit.config.ts --coverage", + "int-test": "vitest --config packages/lib-a/vitest.int.config.ts --coverage", + "code-pushup-coverage": "code-pushup collect --config packages/lib-a/code-pushup.config.ts --cache.write --persist.skipReports --persist.outputDir packages/lib-a/.code-pushup --onlyPlugins=coverage", + "code-pushup": "code-pushup autorun --config packages/lib-a/code-pushup.config.ts --cache.read --persist.outputDir packages/lib-a/.code-pushup" + } +} +``` + +> **Note:** `--cache.write` is used on the collect step to persist each plugin's audit-outputs.json; `--cache.read` is used on the autorun step to reuse those outputs. + +## Turborepo Task Graph + +This configuration creates the following task dependency graph: + +**Legend:** + +- ⚡ = Cached target (via outputs) + +```mermaid +graph TD + A[lib-a:code-pushup ⚡] --> B[lib-a:code-pushup-coverage] + B --> C[lib-a:unit-test ⚡] + B --> D[lib-a:int-test ⚡] +``` + +## Command Line Examples + +```bash +# Run all affected project plugins `coverage` and cache the output if configured +turbo run code-pushup-coverage --filter=[origin/main] + +# Run all affected projects with plugins `coverage` and `js-packages` and upload the report to the portal +turbo run code-pushup --filter=[origin/main] +``` + +This approach has the following benefits: + +1. **Parallel Execution**: Plugins can run in parallel +2. **Finegrained Caching**: Code level cache invalidation enables usage of affected packages filtering +3. **Dependency Management**: Leverage Turborepo task dependencies and its caching strategy +4. **Clear Separation**: Each plugin has its own target for better debugging and maintainability diff --git a/packages/cli/src/lib/implementation/core-config.middleware.ts b/packages/cli/src/lib/implementation/core-config.middleware.ts index f21ecf474..cb334b5b8 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.ts @@ -1,5 +1,7 @@ import { autoloadRc, readRcByPath } from '@code-pushup/core'; import { + type CacheConfig, + type CacheConfigObject, type CoreConfig, DEFAULT_PERSIST_FILENAME, DEFAULT_PERSIST_FORMAT, @@ -41,6 +43,7 @@ export async function coreConfigMiddleware< tsconfig, persist: cliPersist, upload: cliUpload, + cache: cliCache, ...remainingCliOptions } = processArgs; // Search for possible configuration file extensions if path is not given @@ -59,8 +62,10 @@ export async function coreConfigMiddleware< ...rcUpload, ...cliUpload, }); + return { ...(config != null && { config }), + cache: normalizeCache(cliCache), persist: buildPersistConfig(cliPersist, rcPersist), ...(upload != null && { upload }), ...remainingRcConfig, @@ -79,5 +84,15 @@ export const normalizeBooleanWithNegation = ( ? false : ((rcOptions?.[propertyName] as boolean) ?? true); +export const normalizeCache = (cache?: CacheConfig): CacheConfigObject => { + if (cache == null) { + return { write: false, read: false }; + } + if (typeof cache === 'boolean') { + return { write: cache, read: cache }; + } + return { write: cache.write ?? false, read: cache.read ?? false }; +}; + export const normalizeFormats = (formats?: string[]): Format[] => (formats ?? []).flatMap(format => format.split(',') as Format[]); diff --git a/packages/cli/src/lib/implementation/core-config.model.ts b/packages/cli/src/lib/implementation/core-config.model.ts index a5f668bb5..cf36caadf 100644 --- a/packages/cli/src/lib/implementation/core-config.model.ts +++ b/packages/cli/src/lib/implementation/core-config.model.ts @@ -1,4 +1,9 @@ -import type { CoreConfig, Format, UploadConfig } from '@code-pushup/models'; +import type { + CacheConfig, + CoreConfig, + Format, + UploadConfig, +} from '@code-pushup/models'; export type PersistConfigCliOptions = { 'persist.outputDir'?: string; @@ -14,6 +19,12 @@ export type UploadConfigCliOptions = { 'upload.server'?: string; }; +export type CacheConfigCliOptions = { + 'cache.read'?: boolean; + 'cache.write'?: boolean; + cache?: boolean; +}; + export type ConfigCliOptions = { config?: string; tsconfig?: string; @@ -22,4 +33,5 @@ export type ConfigCliOptions = { export type CoreConfigCliOptions = Pick & { upload?: Partial>; + cache?: CacheConfig; }; diff --git a/packages/cli/src/lib/implementation/core-config.options.ts b/packages/cli/src/lib/implementation/core-config.options.ts index bc97e98c7..86151122f 100644 --- a/packages/cli/src/lib/implementation/core-config.options.ts +++ b/packages/cli/src/lib/implementation/core-config.options.ts @@ -1,16 +1,20 @@ import type { Options } from 'yargs'; import type { + CacheConfigCliOptions, PersistConfigCliOptions, UploadConfigCliOptions, } from './core-config.model.js'; export function yargsCoreConfigOptionsDefinition(): Record< - keyof (PersistConfigCliOptions & UploadConfigCliOptions), + keyof (PersistConfigCliOptions & + UploadConfigCliOptions & + CacheConfigCliOptions), Options > { return { ...yargsPersistConfigOptionsDefinition(), ...yargsUploadConfigOptionsDefinition(), + ...yargsCacheConfigOptionsDefinition(), }; } @@ -62,3 +66,23 @@ export function yargsUploadConfigOptionsDefinition(): Record< }, }; } + +export function yargsCacheConfigOptionsDefinition(): Record< + keyof CacheConfigCliOptions, + Options +> { + return { + cache: { + describe: 'Cache runner outputs (both read and write)', + type: 'boolean', + }, + 'cache.read': { + describe: 'Read runner-output.json from file system', + type: 'boolean', + }, + 'cache.write': { + describe: 'Write runner-output.json to file system', + type: 'boolean', + }, + }; +} diff --git a/packages/cli/src/lib/options.ts b/packages/cli/src/lib/options.ts index e1835ed5c..3085affd0 100644 --- a/packages/cli/src/lib/options.ts +++ b/packages/cli/src/lib/options.ts @@ -1,4 +1,5 @@ import { + yargsCacheConfigOptionsDefinition, yargsCoreConfigOptionsDefinition, yargsPersistConfigOptionsDefinition, yargsUploadConfigOptionsDefinition, @@ -17,6 +18,7 @@ export const groups = { ...Object.keys(yargsGlobalOptionsDefinition()), ...Object.keys(yargsFilterOptionsDefinition()), ], + 'Cache Options:': Object.keys(yargsCacheConfigOptionsDefinition()), 'Persist Options:': Object.keys(yargsPersistConfigOptionsDefinition()), 'Upload Options:': Object.keys(yargsUploadConfigOptionsDefinition()), }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c3e18080..f223a1f06 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,11 @@ export { compareReports, type CompareOptions, } from './lib/compare.js'; +export { + getRunnerOutputsPath, + type ValidatedRunnerResult, +} from './lib/implementation/runner.js'; + export { history, type HistoryOnlyOptions, diff --git a/packages/core/src/lib/collect-and-persist.ts b/packages/core/src/lib/collect-and-persist.ts index 8445050d0..414c0275f 100644 --- a/packages/core/src/lib/collect-and-persist.ts +++ b/packages/core/src/lib/collect-and-persist.ts @@ -1,4 +1,5 @@ import { + type CacheConfigObject, type CoreConfig, type PersistConfig, pluginReportSchema, @@ -23,6 +24,7 @@ export type CollectAndPersistReportsOptions = Pick< > & { persist: Required> & Pick; + cache: CacheConfigObject; } & Partial; export async function collectAndPersistReports( diff --git a/packages/core/src/lib/collect-and-persist.unit.test.ts b/packages/core/src/lib/collect-and-persist.unit.test.ts index 49a22c3b1..01eea84a2 100644 --- a/packages/core/src/lib/collect-and-persist.unit.test.ts +++ b/packages/core/src/lib/collect-and-persist.unit.test.ts @@ -58,6 +58,10 @@ describe('collectAndPersistReports', () => { filename: 'report', format: ['md'], }, + cache: { + read: false, + write: false, + }, progress: false, }; await collectAndPersistReports(nonVerboseConfig); @@ -99,6 +103,10 @@ describe('collectAndPersistReports', () => { filename: 'report', format: ['md'], }, + cache: { + read: false, + write: false, + }, progress: false, }; await collectAndPersistReports(verboseConfig); diff --git a/packages/core/src/lib/history.ts b/packages/core/src/lib/history.ts index 9f094bb71..58bdf6ba9 100644 --- a/packages/core/src/lib/history.ts +++ b/packages/core/src/lib/history.ts @@ -1,4 +1,5 @@ import type { + CacheConfigObject, CoreConfig, PersistConfig, UploadConfig, @@ -15,6 +16,7 @@ export type HistoryOnlyOptions = { }; export type HistoryOptions = Pick & { persist: Required; + cache: CacheConfigObject; upload?: Required; } & HistoryOnlyOptions & Partial; @@ -40,6 +42,10 @@ export async function history( format: ['json'], filename: `${commit}-report`, }, + cache: { + read: false, + write: false, + }, }; await collectAndPersistReports(currentConfig); diff --git a/packages/core/src/lib/implementation/collect.int.test.ts b/packages/core/src/lib/implementation/collect.int.test.ts index ec2365aad..2d1e5dc3a 100644 --- a/packages/core/src/lib/implementation/collect.int.test.ts +++ b/packages/core/src/lib/implementation/collect.int.test.ts @@ -1,34 +1,147 @@ -import { vol } from 'memfs'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { commitSchema } from '@code-pushup/models'; -import { MEMFS_VOLUME, MINIMAL_CONFIG_MOCK } from '@code-pushup/test-utils'; +import { MINIMAL_CONFIG_MOCK, cleanTestFolder } from '@code-pushup/test-utils'; +import { + ensureDirectoryExists, + fileExists, + readJsonFile, +} from '@code-pushup/utils'; import { collect } from './collect.js'; +import { getRunnerOutputsPath } from './runner.js'; describe('collect', () => { + const outputDir = path.join('tmp', 'int', 'core', 'collect', '.code-pushup'); + + const expectedCachedOutput = [ + { + slug: 'node-version', + score: 0.3, + value: 16, + displayValue: '16.0.0', + details: { + issues: [ + { + severity: 'error', + message: 'The required Node version to run Code PushUp CLI is 18.', + }, + ], + }, + }, + ]; + + const expectedCachedTestData = [ + { + slug: 'node-version', + score: 0.8, + value: 18, + displayValue: '18.0.0', + details: { issues: [] }, + }, + ]; + + const expectedPluginOutput = { + slug: 'node', + title: 'Node', + icon: 'javascript', + date: expect.any(String), + audits: expectedCachedOutput.map(audit => ({ + ...audit, + title: 'Node version', + description: 'Returns node version', + docsUrl: 'https://nodejs.org/', + })), + }; + + beforeEach(async () => { + await cleanTestFolder(outputDir); + await ensureDirectoryExists(outputDir); + }); + it('should execute with valid options', async () => { - vol.fromJSON({}, MEMFS_VOLUME); const report = await collect({ ...MINIMAL_CONFIG_MOCK, - verbose: true, + persist: { outputDir }, + cache: { read: false, write: false }, progress: false, }); - expect(report.plugins[0]?.audits[0]).toEqual( - expect.objectContaining({ - slug: 'node-version', - displayValue: '16.0.0', - details: { - issues: [ - { - severity: 'error', - message: - 'The required Node version to run Code PushUp CLI is 18.', - }, - ], - }, - }), - ); + expect(report.plugins[0]).toStrictEqual({ + ...expectedPluginOutput, + duration: expect.any(Number), + }); + expect(report.plugins[0]?.duration).toBeGreaterThanOrEqual(0); expect(() => commitSchema.parse(report.commit)).not.toThrow(); + + await expect( + fileExists(getRunnerOutputsPath('node', outputDir)), + ).resolves.toBeFalsy(); + }); + + it('should write runner outputs with --cache.write option', async () => { + const report = await collect({ + ...MINIMAL_CONFIG_MOCK, + persist: { outputDir }, + cache: { read: false, write: true }, + progress: false, + }); + + expect(report.plugins[0]).toStrictEqual({ + ...expectedPluginOutput, + duration: expect.any(Number), + }); + expect(report.plugins[0]?.duration).toBeGreaterThanOrEqual(0); + + await expect( + readJsonFile(getRunnerOutputsPath('node', outputDir)), + ).resolves.toStrictEqual(expectedCachedOutput); + }); + + it('should read runner outputs with --cache.read option and have plugin duraton 0', async () => { + const cacheFilePath = getRunnerOutputsPath('node', outputDir); + await ensureDirectoryExists(path.dirname(cacheFilePath)); + await writeFile(cacheFilePath, JSON.stringify(expectedCachedTestData)); + + const report = await collect({ + ...MINIMAL_CONFIG_MOCK, + persist: { outputDir }, + cache: { read: true, write: false }, + progress: false, + }); + + expect(report.plugins[0]?.audits[0]).toStrictEqual( + expect.objectContaining(expectedCachedTestData[0]), + ); + + expect(report.plugins[0]).toStrictEqual({ + ...expectedPluginOutput, + duration: 0, + audits: expect.any(Array), + }); + + await expect(readJsonFile(cacheFilePath)).resolves.toStrictEqual( + expectedCachedTestData, + ); + }); + + it('should execute runner and write cache with --cache option', async () => { + const report = await collect({ + ...MINIMAL_CONFIG_MOCK, + persist: { outputDir }, + cache: { read: true, write: true }, + progress: false, + }); + + expect(report.plugins[0]).toStrictEqual({ + ...expectedPluginOutput, + duration: expect.any(Number), + }); + expect(report.plugins[0]?.duration).toBeGreaterThanOrEqual(0); + + await expect( + readJsonFile(getRunnerOutputsPath('node', outputDir)), + ).resolves.toStrictEqual(expectedCachedOutput); }); }); diff --git a/packages/core/src/lib/implementation/collect.ts b/packages/core/src/lib/implementation/collect.ts index 59803c406..9a07800ea 100644 --- a/packages/core/src/lib/implementation/collect.ts +++ b/packages/core/src/lib/implementation/collect.ts @@ -1,5 +1,6 @@ import { createRequire } from 'node:module'; import { + type CacheConfigObject, type CoreConfig, DEFAULT_PERSIST_OUTPUT_DIR, type PersistConfig, @@ -11,6 +12,7 @@ import { executePlugins } from './execute-plugin.js'; export type CollectOptions = Pick & { persist?: Required>; + cache: CacheConfigObject; } & Partial; /** @@ -18,7 +20,7 @@ export type CollectOptions = Pick & { * @param options */ export async function collect(options: CollectOptions): Promise { - const { plugins, categories, persist, ...otherOptions } = options; + const { plugins, categories, persist, cache, ...otherOptions } = options; const date = new Date().toISOString(); const start = performance.now(); const commit = await getLatestCommit(); @@ -26,8 +28,7 @@ export async function collect(options: CollectOptions): Promise { { plugins, persist: { outputDir: DEFAULT_PERSIST_OUTPUT_DIR, ...persist }, - // implement together with CLI option - cache: { read: false, write: false }, + cache, }, otherOptions, ); diff --git a/packages/core/src/lib/implementation/runner.int.test.ts b/packages/core/src/lib/implementation/runner.int.test.ts index b26ee6ebe..53acb0036 100644 --- a/packages/core/src/lib/implementation/runner.int.test.ts +++ b/packages/core/src/lib/implementation/runner.int.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { cleanTestFolder } from '@code-pushup/test-utils'; import { ensureDirectoryExists } from '@code-pushup/utils'; import { - getAuditOutputsPath, + getRunnerOutputsPath, readRunnerResults, writeRunnerResults, } from './runner.js'; @@ -23,7 +23,7 @@ describe('readRunnerResults', () => { beforeEach(async () => { await ensureDirectoryExists(cacheDir); await writeFile( - getAuditOutputsPath(pluginSlug, outputDir), + getRunnerOutputsPath(pluginSlug, outputDir), JSON.stringify([ { slug: 'node-version', @@ -77,7 +77,7 @@ describe('writeRunnerResults', () => { beforeEach(async () => { await ensureDirectoryExists(cacheDir); await writeFile( - getAuditOutputsPath(pluginSlug, outputDir), + getRunnerOutputsPath(pluginSlug, outputDir), JSON.stringify([ { slug: 'node-version', diff --git a/packages/core/src/lib/implementation/runner.ts b/packages/core/src/lib/implementation/runner.ts index 517d1e971..afdf17546 100644 --- a/packages/core/src/lib/implementation/runner.ts +++ b/packages/core/src/lib/implementation/runner.ts @@ -131,7 +131,7 @@ function auditOutputsCorrelateWithPluginOutput( }); } -export function getAuditOutputsPath(pluginSlug: string, outputDir: string) { +export function getRunnerOutputsPath(pluginSlug: string, outputDir: string) { return path.join(outputDir, pluginSlug, 'runner-output.json'); } @@ -146,18 +146,16 @@ export async function writeRunnerResults( outputDir: string, runnerResult: ValidatedRunnerResult, ): Promise { - await ensureDirectoryExists(outputDir); - await writeFile( - getAuditOutputsPath(pluginSlug, outputDir), - JSON.stringify(runnerResult.audits, null, 2), - ); + const cacheFilePath = getRunnerOutputsPath(pluginSlug, outputDir); + await ensureDirectoryExists(path.dirname(cacheFilePath)); + await writeFile(cacheFilePath, JSON.stringify(runnerResult.audits, null, 2)); } export async function readRunnerResults( pluginSlug: string, outputDir: string, ): Promise { - const auditOutputsPath = getAuditOutputsPath(pluginSlug, outputDir); + const auditOutputsPath = getRunnerOutputsPath(pluginSlug, outputDir); if (await fileExists(auditOutputsPath)) { const cachedResult = await readJsonFile(auditOutputsPath); diff --git a/packages/core/src/lib/implementation/runner.unit.test.ts b/packages/core/src/lib/implementation/runner.unit.test.ts index a9aea1270..06e962e3a 100644 --- a/packages/core/src/lib/implementation/runner.unit.test.ts +++ b/packages/core/src/lib/implementation/runner.unit.test.ts @@ -13,13 +13,13 @@ import { executePluginRunner, executeRunnerConfig, executeRunnerFunction, - getAuditOutputsPath, + getRunnerOutputsPath, } from './runner.js'; -describe('getAuditOutputsPath', () => { +describe('getRunnerOutputsPath', () => { it('should read runner results from a file', async () => { expect( - osAgnosticPath(getAuditOutputsPath('plugin-with-cache', 'output')), + osAgnosticPath(getRunnerOutputsPath('plugin-with-cache', 'output')), ).toBe(osAgnosticPath('output/plugin-with-cache/runner-output.json')); }); }); diff --git a/packages/models/docs/models-reference.md b/packages/models/docs/models-reference.md index bffd0633f..be7d688c1 100644 --- a/packages/models/docs/models-reference.md +++ b/packages/models/docs/models-reference.md @@ -133,6 +133,36 @@ _Object containing the following properties:_ _(\*) Required._ +## CacheConfigObject + +Cache configuration object for read and/or write operations + +_Object containing the following properties:_ + +| Property | Description | Type | Default | +| :------- | :-------------------------------------- | :-------- | :------ | +| `read` | Whether to read from cache if available | `boolean` | `false` | +| `write` | Whether to write results to cache | `boolean` | `false` | + +_All properties are optional._ + +## CacheConfig + +Cache configuration for read and write operations + +_Union of the following possible types:_ + +- [CacheConfigShorthand](#cacheconfigshorthand) +- [CacheConfigObject](#cacheconfigobject) + +_Default value:_ `false` + +## CacheConfigShorthand + +Cache configuration shorthand for both, read and write operations + +_Boolean._ + ## CategoryConfig _Object containing the following properties:_ @@ -1228,11 +1258,12 @@ _Enum, one of the following possible values:_ _Object containing the following properties:_ -| Property | Description | Type | -| :---------- | :-------------------------------------- | :--------------------------------- | -| `outputDir` | Artifacts folder | [FilePath](#filepath) | -| `filename` | Artifacts file name (without extension) | [FileName](#filename) | -| `format` | | _Array of [Format](#format) items_ | +| Property | Description | Type | +| :------------ | :-------------------------------------- | :--------------------------------- | +| `outputDir` | Artifacts folder | [FilePath](#filepath) | +| `filename` | Artifacts file name (without extension) | [FileName](#filename) | +| `format` | | _Array of [Format](#format) items_ | +| `skipReports` | | `boolean` | _All properties are optional._