diff --git a/eslint.config.js b/eslint.config.js index 8a3e3c150..bc9dd841b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -93,6 +93,12 @@ export default tseslint.config( ], }, }, + { + files: ['**/*.type.test.ts'], + rules: { + 'vitest/expect-expect': 'off', + }, + }, { files: ['**/*.json'], languageOptions: { parser: jsoncParser }, diff --git a/package-lock.json b/package-lock.json index 8dea7dbc3..364f46e21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@code-pushup/portal-client": "^0.13.0", + "@code-pushup/portal-client": "^0.14.3", "@isaacs/cliui": "^8.0.2", "@nx/devkit": "19.8.13", "@poppinss/cliui": "6.4.1", @@ -2334,9 +2334,9 @@ } }, "node_modules/@code-pushup/portal-client": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.13.0.tgz", - "integrity": "sha512-v94sA9zYMCBfQrGRImBRC0iWW+ZvwRJloWTWU06yYXp/+pIHjBqwXuN34IQr1kmSkPC/hZIKc7vwJG7fiYHfAQ==", + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.14.3.tgz", + "integrity": "sha512-1OII0or4Nwg9x7SM7Zgm0AmvVtiK3tlmQQVhFxmQ32on5j/yA1sDAbhjTz3Vnx3GLAF3PYNKu+hkibMYKP67gA==", "license": "MIT", "dependencies": { "graphql": "^16.6.0", diff --git a/package.json b/package.json index b4e776c66..7a931e94e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "node": ">=22.14" }, "dependencies": { - "@code-pushup/portal-client": "^0.13.0", + "@code-pushup/portal-client": "^0.14.3", "@isaacs/cliui": "^8.0.2", "@nx/devkit": "19.8.13", "@poppinss/cliui": "6.4.1", diff --git a/packages/ci/mocks/fixtures/outputs/config.portal.json b/packages/ci/mocks/fixtures/outputs/config.portal.json new file mode 100644 index 000000000..96e0c2c02 --- /dev/null +++ b/packages/ci/mocks/fixtures/outputs/config.portal.json @@ -0,0 +1,27 @@ +{ + "persist": { + "outputDir": ".code-pushup", + "filename": "report", + "format": ["json", "md"] + }, + "upload": { + "server": "https://api.code-pushup.dunder-mifflin.org/graphql", + "apiKey": "cp_abcdef0123456789", + "organization": "dunder-mifflin", + "project": "website" + }, + "categories": [], + "plugins": [ + { + "title": "TypeScript migration", + "slug": "ts-migration", + "icon": "typescript", + "audits": [ + { + "slug": "ts-files", + "title": "Source files converted from JavaScript to TypeScript" + } + ] + } + ] +} diff --git a/packages/ci/mocks/fixtures/outputs/diff-project.json b/packages/ci/mocks/fixtures/outputs/diff-project.json index d120ae588..705d3a64c 100644 --- a/packages/ci/mocks/fixtures/outputs/diff-project.json +++ b/packages/ci/mocks/fixtures/outputs/diff-project.json @@ -4,13 +4,13 @@ "hash": "efed65b3ffab808176c4f8670d77f8d69f71490e", "message": "Initial commit", "date": "2024-10-14T09:00:13.000Z", - "author": "John Doe" + "author": "John Doe " }, "after": { "hash": "2f3b5365432abb7c9949aa50ff3aa8b7a02256de", "message": "Convert JS file to TS", "date": "2024-10-14T09:00:13.000Z", - "author": "John Doe" + "author": "John Doe " } }, "categories": { diff --git a/packages/ci/mocks/fixtures/outputs/report-after.json b/packages/ci/mocks/fixtures/outputs/report-after.json index 96c076a5a..ca2b609ae 100644 --- a/packages/ci/mocks/fixtures/outputs/report-after.json +++ b/packages/ci/mocks/fixtures/outputs/report-after.json @@ -3,7 +3,7 @@ "hash": "99781f731759ef36b2fb7e4a39703965904f5376", "message": "Convert JS file to TS", "date": "2024-10-14T09:55:21.000Z", - "author": "John Doe" + "author": "John Doe " }, "packageName": "@code-pushup/core", "version": "0.51.0", diff --git a/packages/ci/mocks/fixtures/outputs/report-before.json b/packages/ci/mocks/fixtures/outputs/report-before.json index 7c3062578..1613f4b46 100644 --- a/packages/ci/mocks/fixtures/outputs/report-before.json +++ b/packages/ci/mocks/fixtures/outputs/report-before.json @@ -3,7 +3,7 @@ "hash": "efed65b3ffab808176c4f8670d77f8d69f71490e", "message": "Initial commit", "date": "2024-10-14T09:00:13.000Z", - "author": "John Doe" + "author": "John Doe " }, "packageName": "@code-pushup/core", "version": "0.51.0", diff --git a/packages/ci/mocks/fixtures/outputs/report-before.portal.json b/packages/ci/mocks/fixtures/outputs/report-before.portal.json new file mode 100644 index 000000000..b8f66972c --- /dev/null +++ b/packages/ci/mocks/fixtures/outputs/report-before.portal.json @@ -0,0 +1,82 @@ +{ + "__typename": "Report", + "commit": { + "__typename": "Commit", + "sha": "efed65b3ffab808176c4f8670d77f8d69f71490e", + "message": "Initial commit", + "date": "2024-10-14T09:00:13.000Z", + "author": { + "__typename": "CommitAuthor", + "name": "John Doe", + "email": "john.doe@example.com" + } + }, + "packageName": "@code-pushup/core", + "packageVersion": "0.51.0", + "commandStartDate": "2024-10-14T09:00:14.969Z", + "commandDuration": 14, + "categories": [], + "plugins": [ + { + "__typename": "Plugin", + "slug": "ts-migration", + "title": "TypeScript migration", + "icon": "typescript", + "description": null, + "docsUrl": null, + "packageName": null, + "packageVersion": null, + "runnerStartDate": "2024-10-14T09:00:14.978Z", + "runnerDuration": 2, + "audits": { + "edges": [ + { + "node": { + "__typename": "Audit", + "slug": "ts-files", + "title": "Source files converted from JavaScript to TypeScript", + "description": null, + "docsUrl": null, + "score": 0.5, + "value": 50, + "formattedValue": "50% converted", + "details": { + "enabled": true, + "tables": [], + "trees": [] + } + } + } + ] + }, + "groups": [] + } + ], + "issues": { + "edges": [ + { + "node": { + "__typename": "Issue", + "message": "Use .ts file extension instead of .js", + "severity": "WARNING", + "audit": { + "__typename": "Audit", + "slug": "ts-files", + "plugin": { + "__typename": "Plugin", + "slug": "ts-migration" + } + }, + "source": { + "__typename": "SourceCodeLocation", + "filePath": "index.js", + "startLine": null, + "startColumn": null, + "endLine": null, + "endColumn": null + } + } + } + ] + } +} diff --git a/packages/ci/package.json b/packages/ci/package.json index df7a19482..32002f89b 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -27,6 +27,7 @@ "type": "module", "dependencies": { "@code-pushup/models": "0.70.0", + "@code-pushup/portal-client": "^0.14.3", "@code-pushup/utils": "0.70.0", "glob": "^11.0.1", "simple-git": "^3.20.0", diff --git a/packages/ci/src/lib/cli/index.ts b/packages/ci/src/lib/cli/index.ts index f252efdc7..d2474edbc 100644 --- a/packages/ci/src/lib/cli/index.ts +++ b/packages/ci/src/lib/cli/index.ts @@ -3,4 +3,8 @@ export { runCompare } from './commands/compare.js'; export { runMergeDiffs } from './commands/merge-diffs.js'; export { runPrintConfig } from './commands/print-config.js'; export { createCommandContext, type CommandContext } from './context.js'; -export { persistedFilesFromConfig } from './persist.js'; +export { + parsePersistConfig, + persistedFilesFromConfig, + type EnhancedPersistConfig, +} from './persist.js'; diff --git a/packages/ci/src/lib/cli/persist.ts b/packages/ci/src/lib/cli/persist.ts index b7b8ce224..843f13d60 100644 --- a/packages/ci/src/lib/cli/persist.ts +++ b/packages/ci/src/lib/cli/persist.ts @@ -7,11 +7,14 @@ import { DEFAULT_PERSIST_OUTPUT_DIR, type Format, persistConfigSchema, + uploadConfigSchema, } from '@code-pushup/models'; -import { objectFromEntries, stringifyError } from '@code-pushup/utils'; +import { objectFromEntries } from '@code-pushup/utils'; + +export type EnhancedPersistConfig = Pick; export function persistedFilesFromConfig( - config: Pick, + config: EnhancedPersistConfig, { isDiff, directory }: { isDiff?: boolean; directory: string }, ): Record { const { @@ -36,11 +39,16 @@ export function persistedFilesFromConfig( export async function parsePersistConfig( json: unknown, -): Promise> { - const schema = z.object({ persist: persistConfigSchema.optional() }); +): Promise { + const schema = z.object({ + persist: persistConfigSchema.optional(), + upload: uploadConfigSchema.optional(), + }); const result = await schema.safeParseAsync(json); if (result.error) { - throw new Error(`Invalid persist config - ${stringifyError(result.error)}`); + throw new Error( + `Code PushUp config is invalid:\n${z.prettifyError(result.error)}`, + ); } return result.data; } diff --git a/packages/ci/src/lib/cli/persist.unit.test.ts b/packages/ci/src/lib/cli/persist.unit.test.ts index 874b78258..604e50149 100644 --- a/packages/ci/src/lib/cli/persist.unit.test.ts +++ b/packages/ci/src/lib/cli/persist.unit.test.ts @@ -1,6 +1,10 @@ import path from 'node:path'; import type { CoreConfig } from '@code-pushup/models'; -import { parsePersistConfig, persistedFilesFromConfig } from './persist.js'; +import { + type EnhancedPersistConfig, + parsePersistConfig, + persistedFilesFromConfig, +} from './persist.js'; describe('persistedFilesFromConfig', () => { it('should return default report paths when no config is set', () => { @@ -72,7 +76,7 @@ describe('persistedFilesFromConfig', () => { }); describe('parsePersistConfig', () => { - it('should validate only persist config', async () => { + it('should validate only persist and upload config', async () => { await expect( parsePersistConfig({ persist: { @@ -80,9 +84,39 @@ describe('parsePersistConfig', () => { filename: 'report', format: ['json', 'md'], }, + upload: { + server: 'https://code-pushup-api.dunder-mifflin.org/graphql', + apiKey: 'cp_abcdef0123456789', + organization: 'dunder-mifflin', + project: 'website', + }, // missing props (slug, etc.) plugins: [{ title: 'some plugin', audits: [{ title: 'some audit' }] }], } as CoreConfig), + ).resolves.toEqual({ + persist: { + outputDir: '.code-pushup', + filename: 'report', + format: ['json', 'md'], + }, + upload: { + server: 'https://code-pushup-api.dunder-mifflin.org/graphql', + apiKey: 'cp_abcdef0123456789', + organization: 'dunder-mifflin', + project: 'website', + }, + } satisfies EnhancedPersistConfig); + }); + + it('should accept missing upload config', async () => { + await expect( + parsePersistConfig({ + persist: { + outputDir: '.code-pushup', + filename: 'report', + format: ['json', 'md'], + }, + }), ).resolves.toEqual({ persist: { outputDir: '.code-pushup', @@ -114,7 +148,20 @@ describe('parsePersistConfig', () => { await expect( parsePersistConfig({ persist: { format: ['json', 'html'] } }), ).rejects.toThrow( - /^Invalid persist config - ZodError:.*Invalid option: expected one of \\"json\\"\|\\"md\\"/s, + /^Code PushUp config is invalid.*Invalid option: expected one of "json"\|"md".*at persist\.format\[1]/s, + ); + }); + + it('should error if upload config is invalid', async () => { + await expect( + parsePersistConfig({ + upload: { + organization: 'dunder-mifflin', + project: 'website', + }, + }), + ).rejects.toThrow( + /^Code PushUp config is invalid.*Invalid input: expected string, received undefined.*at upload\.server.*at upload\.apiKey/s, ); }); }); diff --git a/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap b/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap new file mode 100644 index 000000000..6786b87f0 --- /dev/null +++ b/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap @@ -0,0 +1,205 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`transformGQLReport > should convert full GraphQL report to valid report.json format 1`] = ` +{ + "categories": [ + { + "isBinary": false, + "refs": [ + { + "plugin": "eslint", + "slug": "suggestions", + "type": "group", + "weight": 1, + }, + ], + "slug": "code-style", + "title": "Code style", + }, + { + "isBinary": false, + "refs": [ + { + "plugin": "bundle-stats", + "slug": "initial", + "type": "audit", + "weight": 1, + }, + ], + "slug": "bundle-size", + "title": "Bundle size", + }, + ], + "commit": { + "author": "John Doe ", + "date": 2025-08-01T00:00:00.000Z, + "hash": "4da737d63efcc83d0dd05620801f195968611eb7", + "message": "Apply suggestions from code review", + }, + "date": "2025-08-01T00:10:00.000Z", + "duration": 30000, + "packageName": "@code-pushup/core", + "plugins": [ + { + "audits": [ + { + "details": { + "issues": [ + { + "message": "File has too many lines (420). Maximum allowed is 300.", + "severity": "warning", + "source": { + "file": "src/main.ts", + "position": { + "endLine": 420, + "startLine": 301, + }, + }, + }, + ], + }, + "displayValue": "1 warning", + "score": 0, + "slug": "max-lines", + "title": "Enforce a maximum number of lines per file", + "value": 1, + }, + { + "details": {}, + "displayValue": "passed", + "score": 1, + "slug": "max-lines-per-function", + "title": "Enforce a maximum number of lines of code per function", + "value": 0, + }, + ], + "date": "2025-08-01T00:10:00.000Z", + "duration": 20000, + "groups": [ + { + "refs": [ + { + "slug": "max-lines", + "weight": 1, + }, + { + "slug": "max-lines-per-function", + "weight": 1, + }, + ], + "slug": "suggestions", + "title": "Suggestion", + }, + ], + "icon": "eslint", + "packageName": "@code-pushup/eslint-plugin", + "slug": "eslint", + "title": "ESLint", + "version": "0.42.0", + }, + { + "audits": [ + { + "details": { + "issues": [ + { + "message": "\`main.js\` is **420 kB**, exceeds warning threshold of 350 kB", + "severity": "warning", + }, + ], + "table": { + "columns": [ + { + "align": "left", + "key": "0", + "label": "Group", + }, + { + "align": "right", + "key": "1", + "label": "Size", + }, + { + "align": "right", + "key": "2", + "label": "Modules", + }, + ], + "rows": [ + { + "0": "3rd-party packages", + "1": "321.4 kB", + "2": "101", + }, + { + "0": "Application shell", + "1": "98.6 kB", + "2": "7", + }, + ], + }, + "trees": [ + { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "src/main.ts", + "values": { + "size": "275 kB", + }, + }, + { + "name": "src/utils/format.ts", + "values": { + "size": "120 kB", + }, + }, + { + "name": "src/utils/math.ts", + "values": { + "size": "25 kB", + }, + }, + ], + "name": "inputs", + }, + ], + "name": "dist/main.js", + "values": { + "size": "420 kB", + }, + }, + ], + "name": "outputs", + }, + ], + "name": "stats.json", + }, + "type": "basic", + }, + ], + }, + "displayValue": "420 kB", + "score": 0.75, + "slug": "initial", + "title": "Initial JavaScript bundle", + "value": 420000, + }, + ], + "date": "2025-08-01T00:10:20.000Z", + "duration": 10000, + "groups": [], + "icon": "javascript-map", + "slug": "bundle-stats", + "title": "Bundle stats", + }, + ], + "version": "0.42.0", +} +`; diff --git a/packages/ci/src/lib/portal/download.ts b/packages/ci/src/lib/portal/download.ts new file mode 100644 index 000000000..3a07676ed --- /dev/null +++ b/packages/ci/src/lib/portal/download.ts @@ -0,0 +1,30 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + type PortalDownloadArgs, + downloadFromPortal, +} from '@code-pushup/portal-client'; +import { transformGQLReport } from './transform.js'; + +export async function downloadReportFromPortal( + args: PortalDownloadArgs, +): Promise { + const gqlReport = await downloadFromPortal(args); + if (!gqlReport) { + return null; + } + + const report = transformGQLReport(gqlReport); + + const outputFile = path.join( + 'tmp', + 'code-pushup', + 'portal', + args.parameters.organization, + args.parameters.project, + 'report.json', + ); + await mkdir(path.dirname(outputFile), { recursive: true }); + await writeFile(outputFile, JSON.stringify(report, null, 2)); + return outputFile; +} diff --git a/packages/ci/src/lib/portal/index.ts b/packages/ci/src/lib/portal/index.ts new file mode 100644 index 000000000..9284cfb4c --- /dev/null +++ b/packages/ci/src/lib/portal/index.ts @@ -0,0 +1 @@ +export { downloadReportFromPortal } from './download.js'; diff --git a/packages/ci/src/lib/portal/transform.ts b/packages/ci/src/lib/portal/transform.ts new file mode 100644 index 000000000..760ea3850 --- /dev/null +++ b/packages/ci/src/lib/portal/transform.ts @@ -0,0 +1,251 @@ +import type { + AuditFragment, + BasicTreeNodeDataFragment, + CategoryFragment, + CommitFragment, + CoverageTreeNodeDataFragment, + GroupFragment, + IssueFragment, + PluginFragment, + ReportFragment, + TableFragment, + TreeFragment, +} from '@code-pushup/portal-client'; +import type { + AuditReport, + BasicTreeNode, + CategoryConfig, + CategoryRef, + Commit, + CoverageTreeNode, + Group, + Issue, + PluginReport, + Report, + Table, + Tree, +} from '@code-pushup/models'; +import { lowercase } from '@code-pushup/utils'; + +export function transformGQLReport(report: ReportFragment): Report { + return { + commit: transformGQLCommit(report.commit), + plugins: report.plugins.map(plugin => + transformGQLPlugin( + plugin, + report.issues?.edges.map(({ node }) => node) ?? [], + ), + ), + categories: report.categories.map(transformGQLCategory), + // TODO: make report metadata required in Portal API? + packageName: report.packageName ?? 'unknown', + version: report.packageVersion ?? 'unknown', + date: report.commandStartDate ?? '', + duration: report.commandDuration ?? 0, + }; +} + +function transformGQLCommit(commit: CommitFragment): Commit { + return { + hash: commit.sha, + message: commit.message, + // TODO: make commit author and date required in Portal API? + author: commit.author + ? `${commit.author.name} <${commit.author.email}>` + : 'unknown', + date: commit.date ? new Date(commit.date) : new Date(), + }; +} + +function transformGQLCategory(category: CategoryFragment): CategoryConfig { + return { + slug: category.slug, + title: category.title, + isBinary: category.isBinary, + ...(category.description && { description: category.description }), + refs: category.refs.map( + ({ target, weight }): CategoryRef => ({ + type: lowercase(target.__typename), + plugin: target.plugin.slug, + slug: target.slug, + weight, + }), + ), + }; +} + +function transformGQLPlugin( + plugin: PluginFragment, + issues: IssueFragment[], +): PluginReport { + return { + slug: plugin.slug, + title: plugin.title, + icon: plugin.icon, + ...(plugin.description && { description: plugin.description }), + ...(plugin.docsUrl && { docsUrl: plugin.docsUrl }), + audits: plugin.audits.edges.map(({ node }) => + transformGQLAudit( + node, + issues.filter( + issue => + issue.audit.plugin.slug === plugin.slug && + issue.audit.slug === node.slug, + ), + ), + ), + groups: plugin.groups.map(transformGQLGroup), + ...(plugin.packageName && { packageName: plugin.packageName }), + ...(plugin.packageVersion && { version: plugin.packageVersion }), + // TODO: make plugin metadata required in Portal API? + date: plugin.runnerStartDate ?? '', + duration: plugin.runnerDuration ?? 0, + }; +} + +function transformGQLGroup(group: GroupFragment): Group { + return { + slug: group.slug, + title: group.title, + ...(group.description && { description: group.description }), + refs: group.refs.map(({ target, weight }) => ({ + slug: target.slug, + weight, + })), + }; +} + +function transformGQLAudit( + audit: AuditFragment, + issues: IssueFragment[], +): AuditReport { + return { + slug: audit.slug, + title: audit.title, + ...(audit.description && { description: audit.description }), + ...(audit.docsUrl && { docsUrl: audit.docsUrl }), + score: audit.score, + value: audit.value, + ...(audit.formattedValue && { displayValue: audit.formattedValue }), + ...(audit.details?.enabled && { + details: { + ...(issues.length > 0 && { + issues: issues.map(transformGQLIssue), + }), + ...(audit.details.tables[0] && { + table: transformGQLTable(audit.details.tables[0]), + }), + ...(audit.details.trees.length > 0 && { + trees: audit.details.trees.map(transformGQLTree), + }), + }, + }), + }; +} + +function transformGQLIssue(issue: IssueFragment): Issue { + return { + message: issue.message, + severity: lowercase(issue.severity), + ...(issue.source?.__typename === 'SourceCodeLocation' && { + source: { + file: issue.source.filePath, + position: { + startLine: issue.source.startLine ?? 0, + ...(issue.source.startColumn != null && { + startColumn: issue.source.startColumn, + }), + ...(issue.source.endLine != null && { + endLine: issue.source.endLine, + }), + ...(issue.source.endColumn != null && { + endColumn: issue.source.endColumn, + }), + }, + }, + }), + }; +} + +function transformGQLTable(table: TableFragment): Table { + if (!table.header) { + return { + ...(table.title && { title: table.title }), + rows: table.body.map(cells => cells.map(cell => cell.content)), + }; + } + + return { + ...(table.title && { title: table.title }), + columns: table.header.map(({ content, alignment }, idx) => ({ + key: idx.toString(), + label: content, + align: lowercase(alignment), + })), + rows: table.body.map(cells => + Object.fromEntries(cells.map((cell, idx) => [idx, cell.content])), + ), + }; +} + +function transformGQLTree(tree: TreeFragment): Tree { + switch (tree.__typename) { + case 'BasicTree': + return { + type: 'basic', + ...(tree.title && { title: tree.title }), + root: transformGQLBasicTreeNode(tree.root), + }; + case 'CoverageTree': + return { + type: 'coverage', + ...(tree.title && { title: tree.title }), + root: transformGQLCoverageTreeNode(tree.root), + }; + } +} + +// widens limited-depth GraphQL fragment to recursive data structure +type TreeNodeGQL = T & { + children?: TreeNodeGQL[] | null; +}; + +function transformGQLBasicTreeNode( + node: TreeNodeGQL, +): BasicTreeNode { + return { + name: node.name, + ...(node.customValues && { + values: Object.fromEntries( + node.customValues.map(({ key, value }) => [key, value]), + ), + }), + ...(node.children && { + children: node.children.map(transformGQLBasicTreeNode), + }), + }; +} + +function transformGQLCoverageTreeNode( + node: TreeNodeGQL, +): CoverageTreeNode { + return { + name: node.name, + values: { + coverage: node.values.coverage, + ...(node.values.missing && { + missing: node.values.missing.map(missing => ({ + ...(missing.kind && { kind: missing.kind }), + ...(missing.name && { name: missing.name }), + startLine: missing.startLine, + ...(missing.startColumn && { startColumn: missing.startColumn }), + ...(missing.endLine && { endLine: missing.endLine }), + ...(missing.endColumn && { endColumn: missing.endColumn }), + })), + }), + }, + ...(node.children && { + children: node.children.map(transformGQLCoverageTreeNode), + }), + }; +} diff --git a/packages/ci/src/lib/portal/transform.unit.test.ts b/packages/ci/src/lib/portal/transform.unit.test.ts new file mode 100644 index 000000000..07212b147 --- /dev/null +++ b/packages/ci/src/lib/portal/transform.unit.test.ts @@ -0,0 +1,245 @@ +import { + IssueSeverity, + type ReportFragment, + TableAlignment, +} from '@code-pushup/portal-client'; +import { reportSchema } from '@code-pushup/models'; +import { transformGQLReport } from './transform.js'; + +describe('transformGQLReport', () => { + const GQL_REPORT: ReportFragment = { + commit: { + sha: '4da737d63efcc83d0dd05620801f195968611eb7', + message: 'Apply suggestions from code review', + author: { + name: 'John Doe', + email: 'john.doe@example.com', + }, + date: '2025-08-01T00:00:00.000Z', + }, + packageName: '@code-pushup/core', + packageVersion: '0.42.0', + commandStartDate: '2025-08-01T00:10:00.000Z', + commandDuration: 30_000, + categories: [ + { + slug: 'code-style', + title: 'Code style', + isBinary: false, + score: 0.5, + refs: [ + { + target: { + __typename: 'Group', + plugin: { slug: 'eslint' }, + slug: 'suggestions', + }, + weight: 1, + }, + ], + }, + { + slug: 'bundle-size', + title: 'Bundle size', + isBinary: false, + score: 0.75, + refs: [ + { + target: { + __typename: 'Audit', + plugin: { slug: 'bundle-stats' }, + slug: 'initial', + }, + weight: 1, + }, + ], + }, + ], + plugins: [ + { + slug: 'eslint', + title: 'ESLint', + icon: 'eslint', + packageName: '@code-pushup/eslint-plugin', + packageVersion: '0.42.0', + runnerStartDate: '2025-08-01T00:10:00.000Z', + runnerDuration: 20_000, + audits: { + edges: [ + { + node: { + slug: 'max-lines', + title: 'Enforce a maximum number of lines per file', + score: 0, + value: 1, + formattedValue: '1 warning', + details: { enabled: true, trees: [], tables: [] }, + }, + }, + { + node: { + slug: 'max-lines-per-function', + title: 'Enforce a maximum number of lines of code per function', + score: 1, + value: 0, + formattedValue: 'passed', + details: { enabled: true, trees: [], tables: [] }, + }, + }, + ], + }, + groups: [ + { + slug: 'suggestions', + title: 'Suggestion', + score: 0.5, + refs: [ + { target: { slug: 'max-lines' }, weight: 1 }, + { target: { slug: 'max-lines-per-function' }, weight: 1 }, + ], + }, + ], + }, + { + slug: 'bundle-stats', + title: 'Bundle stats', + icon: 'javascript-map', + runnerStartDate: '2025-08-01T00:10:20.000Z', + runnerDuration: 10_000, + audits: { + edges: [ + { + node: { + slug: 'initial', + title: 'Initial JavaScript bundle', + score: 0.75, + value: 420_000, + formattedValue: '420 kB', + details: { + enabled: true, + tables: [ + { + header: [ + { content: 'Group', alignment: TableAlignment.Left }, + { content: 'Size', alignment: TableAlignment.Right }, + { content: 'Modules', alignment: TableAlignment.Right }, + ], + body: [ + [ + { + content: '3rd-party packages', + alignment: TableAlignment.Left, + }, + { + content: '321.4 kB', + alignment: TableAlignment.Right, + }, + { + content: '101', + alignment: TableAlignment.Right, + }, + ], + [ + { + content: 'Application shell', + alignment: TableAlignment.Left, + }, + { + content: '98.6 kB', + alignment: TableAlignment.Right, + }, + { + content: '7', + alignment: TableAlignment.Right, + }, + ], + ], + }, + ], + trees: [ + { + __typename: 'BasicTree', + root: { + name: 'stats.json', + children: [ + { + name: 'outputs', + children: [ + { + name: 'dist/main.js', + customValues: [ + { key: 'size', value: '420 kB' }, + ], + children: [ + { + name: 'inputs', + children: [ + { + name: 'src/main.ts', + customValues: [ + { key: 'size', value: '275 kB' }, + ], + }, + { + name: 'src/utils/format.ts', + customValues: [ + { key: 'size', value: '120 kB' }, + ], + }, + { + name: 'src/utils/math.ts', + customValues: [ + { key: 'size', value: '25 kB' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + ], + }, + groups: [], + }, + ], + issues: { + edges: [ + { + node: { + audit: { plugin: { slug: 'eslint' }, slug: 'max-lines' }, + message: 'File has too many lines (420). Maximum allowed is 300.', + severity: IssueSeverity.Warning, + source: { + __typename: 'SourceCodeLocation', + filePath: 'src/main.ts', + startLine: 301, + endLine: 420, + }, + }, + }, + { + node: { + audit: { plugin: { slug: 'bundle-stats' }, slug: 'initial' }, + message: + '`main.js` is **420 kB**, exceeds warning threshold of 350 kB', + severity: IssueSeverity.Warning, + }, + }, + ], + }, + }; + + it('should convert full GraphQL report to valid report.json format', () => { + const report = transformGQLReport(GQL_REPORT); + expect(() => reportSchema.parse(report)).not.toThrow(); + expect(report).toMatchSnapshot(); + }); +}); diff --git a/packages/ci/src/lib/run-monorepo.ts b/packages/ci/src/lib/run-monorepo.ts index cc3207905..76c3d0f93 100644 --- a/packages/ci/src/lib/run-monorepo.ts +++ b/packages/ci/src/lib/run-monorepo.ts @@ -1,5 +1,4 @@ import { readFile } from 'node:fs/promises'; -import type { CoreConfig } from '@code-pushup/models'; import { type ExcludeNullableProps, asyncSequential, @@ -7,6 +6,7 @@ import { } from '@code-pushup/utils'; import { type CommandContext, + type EnhancedPersistConfig, createCommandContext, persistedFilesFromConfig, runCollect, @@ -88,7 +88,7 @@ export async function runInMonorepoMode( type ProjectReport = { project: ProjectConfig; reports: OutputFiles; - config: Pick; + config: EnhancedPersistConfig; ctx: CommandContext; }; @@ -125,7 +125,7 @@ async function runProjectsInBulk( const hasFormats = allProjectsHaveDefaultPersistFormats(currProjectConfigs); logger.debug( [ - `Loaded ${currProjectConfigs.length} persist configs by running print-config command for each project.`, + `Loaded ${currProjectConfigs.length} persist and upload configs by running print-config command for each project.`, hasFormats ? 'Every project has default persist formats.' : 'Not all projects have default persist formats.', @@ -163,7 +163,7 @@ async function compareProjectsInBulk( ): Promise { const projectReportsWithCache = await Promise.all( currProjectReports.map(async ({ project, ctx, reports, config }) => { - const args = { project, base, ctx, env }; + const args = { project, config, base, ctx, env }; const [currReport, prevReport] = await Promise.all([ readFile(reports.json, 'utf8').then( (content): ReportData<'current'> => ({ @@ -279,7 +279,7 @@ async function collectPreviousReports( async function savePreviousProjectReport(args: { name: string; ctx: CommandContext; - config: Pick; + config: EnhancedPersistConfig; settings: Settings; }): Promise<[string, ReportData<'previous'>]> { const { name, ctx, config, settings } = args; @@ -320,7 +320,7 @@ async function collectMany( } export function allProjectsHaveDefaultPersistFormats( - projects: { config: Pick }[], + projects: { config: EnhancedPersistConfig }[], ): boolean { return projects.every(({ config }) => hasDefaultPersistFormats(config)); } diff --git a/packages/ci/src/lib/run-utils.ts b/packages/ci/src/lib/run-utils.ts index f11db536a..64ff36cf6 100644 --- a/packages/ci/src/lib/run-utils.ts +++ b/packages/ci/src/lib/run-utils.ts @@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises'; import type { SimpleGit } from 'simple-git'; import { - type CoreConfig, DEFAULT_PERSIST_FORMAT, type Report, type ReportsDiff, @@ -13,13 +12,14 @@ import { } from '@code-pushup/utils'; import { type CommandContext, + type EnhancedPersistConfig, createCommandContext, + parsePersistConfig, persistedFilesFromConfig, runCollect, runCompare, runPrintConfig, } from './cli/index.js'; -import { parsePersistConfig } from './cli/persist.js'; import { DEFAULT_SETTINGS } from './constants.js'; import { listChangedFiles, normalizeGitRef } from './git.js'; import { type SourceFileIssue, filterRelevantIssues } from './issues.js'; @@ -34,6 +34,7 @@ import type { } from './models.js'; import type { ProjectConfig } from './monorepo/index.js'; import { saveOutputFiles } from './output-files.js'; +import { downloadReportFromPortal } from './portal/download.js'; export type RunEnv = { refs: NormalizedGitRefs; @@ -53,11 +54,12 @@ export type CompareReportsArgs = { base: GitBranch; currReport: ReportData<'current'>; prevReport: ReportData<'previous'>; - config: Pick; + config: EnhancedPersistConfig; }; export type BaseReportArgs = { project: ProjectConfig | null; + config: EnhancedPersistConfig; env: RunEnv; base: GitBranch; ctx: CommandContext; @@ -114,7 +116,7 @@ export async function runOnProject( const config = await printPersistConfig(ctx); logger.debug( - `Loaded persist config from print-config command - ${JSON.stringify(config.persist)}`, + `Loaded persist and upload configs from print-config command - ${JSON.stringify(config)}`, ); await runCollect(ctx, { hasFormats: hasDefaultPersistFormats(config) }); @@ -139,7 +141,8 @@ export async function runOnProject( `PR/MR detected, preparing to compare base branch ${base.ref} to head ${head.ref}`, ); - const prevReport = await collectPreviousReport({ project, env, base, ctx }); + const baseArgs: BaseReportArgs = { project, env, base, config, ctx }; + const prevReport = await collectPreviousReport(baseArgs); if (!prevReport) { return noDiffOutput; } @@ -243,37 +246,89 @@ export async function loadCachedBaseReport( ): Promise | null> { const { project, + env: { settings }, + } = args; + + const cachedBaseReport = + (await loadCachedBaseReportFromPortal(args)) ?? + (await loadCachedBaseReportFromArtifacts(args)); + + if (!cachedBaseReport) { + return null; + } + return saveReportFiles({ + project, + type: 'previous', + files: { json: cachedBaseReport }, + settings, + }); +} + +async function loadCachedBaseReportFromArtifacts( + args: BaseReportArgs, +): Promise { + const { env: { api, settings }, + project, } = args; const { logger } = settings; - const cachedBaseReport = await api - .downloadReportArtifact?.(project?.name) + if (api.downloadReportArtifact == null) { + return null; + } + + const reportPath = await api + .downloadReportArtifact(project?.name) .catch((error: unknown) => { logger.warn( `Error when downloading previous report artifact, skipping - ${stringifyError(error)}`, ); + return null; }); - if (api.downloadReportArtifact != null) { - logger.info( - `Previous report artifact ${cachedBaseReport ? 'found' : 'not found'}`, - ); - if (cachedBaseReport) { - logger.debug( - `Previous report artifact downloaded to ${cachedBaseReport}`, - ); - } + + logger.info(`Previous report artifact ${reportPath ? 'found' : 'not found'}`); + if (reportPath) { + logger.debug(`Previous report artifact downloaded to ${reportPath}`); } - if (!cachedBaseReport) { + return reportPath; +} + +async function loadCachedBaseReportFromPortal( + args: BaseReportArgs, +): Promise { + const { + config, + env: { settings }, + } = args; + const { logger } = settings; + + if (!config.upload) { return null; } - return saveReportFiles({ - project, - type: 'previous', - files: { json: cachedBaseReport }, - settings, + + const reportPath = await downloadReportFromPortal({ + server: config.upload.server, + apiKey: config.upload.apiKey, + parameters: { + organization: config.upload.organization, + project: config.upload.project, + }, + }).catch((error: unknown) => { + logger.warn( + `Error when downloading previous report from portal, skipping - ${stringifyError(error)}`, + ); + return null; }); + + logger.info( + `Previous report ${reportPath ? 'found' : 'not found'} in Code PushUp portal`, + ); + if (reportPath) { + logger.debug(`Previous report downloaded from portal to ${reportPath}`); + } + + return reportPath; } export async function runInBaseBranch( @@ -300,7 +355,7 @@ export async function runInBaseBranch( export async function checkPrintConfig( args: BaseReportArgs, -): Promise | null> { +): Promise { const { project, ctx, @@ -329,13 +384,13 @@ export async function checkPrintConfig( export async function printPersistConfig( ctx: CommandContext, -): Promise> { +): Promise { const json = await runPrintConfig(ctx); return parsePersistConfig(json); } export function hasDefaultPersistFormats( - config: Pick, + config: EnhancedPersistConfig, ): boolean { const formats = config.persist?.format; return ( diff --git a/packages/ci/src/lib/run.int.test.ts b/packages/ci/src/lib/run.int.test.ts index acd8b8ace..26b32d574 100644 --- a/packages/ci/src/lib/run.int.test.ts +++ b/packages/ci/src/lib/run.int.test.ts @@ -10,6 +10,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { type SimpleGit, simpleGit } from 'simple-git'; import { type MockInstance, expect } from 'vitest'; +import { + type ReportFragment, + downloadFromPortal, +} from '@code-pushup/portal-client'; import type { CoreConfig } from '@code-pushup/models'; import { cleanTestFolder, @@ -29,40 +33,57 @@ import type { import type { MonorepoTool } from './monorepo/index.js'; import { runInCI } from './run.js'; -describe('runInCI', () => { - const fixturesDir = path.join( - fileURLToPath(path.dirname(import.meta.url)), - '..', - '..', - 'mocks', - 'fixtures', - ); - const reportsDir = path.join(fixturesDir, 'outputs'); - const workDir = path.join(process.cwd(), 'tmp', 'ci', 'run-test'); - - const fixturePaths = { - reports: { - before: { - json: path.join(reportsDir, 'report-before.json'), - md: path.join(reportsDir, 'report-before.md'), - }, - after: { - json: path.join(reportsDir, 'report-after.json'), - md: path.join(reportsDir, 'report-after.md'), - }, +vi.mock('@code-pushup/portal-client', async importOriginal => { + const mod: typeof import('@code-pushup/portal-client') = + await importOriginal(); + return { + ...mod, + downloadFromPortal: vi.fn(simulateDownloadFromPortal), + }; +}); + +const fixturesDir = path.join( + fileURLToPath(path.dirname(import.meta.url)), + '..', + '..', + 'mocks', + 'fixtures', +); +const reportsDir = path.join(fixturesDir, 'outputs'); +const workDir = path.join(process.cwd(), 'tmp', 'ci', 'run-test'); + +const fixturePaths = { + reports: { + before: { + json: path.join(reportsDir, 'report-before.json'), + md: path.join(reportsDir, 'report-before.md'), + portal: path.join(reportsDir, 'report-before.portal.json'), }, - diffs: { - project: { - json: path.join(reportsDir, 'diff-project.json'), - md: path.join(reportsDir, 'diff-project.md'), - }, - merged: { - md: path.join(reportsDir, 'diff-merged.md'), - }, + after: { + json: path.join(reportsDir, 'report-after.json'), + md: path.join(reportsDir, 'report-after.md'), }, - config: path.join(reportsDir, 'config.json'), - }; + }, + diffs: { + project: { + json: path.join(reportsDir, 'diff-project.json'), + md: path.join(reportsDir, 'diff-project.md'), + }, + merged: { + md: path.join(reportsDir, 'diff-merged.md'), + }, + }, + config: { + base: path.join(reportsDir, 'config.json'), + portal: path.join(reportsDir, 'config.portal.json'), + }, +}; + +function simulateDownloadFromPortal() { + return utils.readJsonFile(fixturePaths.reports.before.portal); +} +describe('runInCI', () => { const logger: Logger = { error: vi.fn(), warn: vi.fn(), @@ -87,6 +108,8 @@ describe('runInCI', () => { onStdout: expect.any(Function), }); + let includeUploadConfig: boolean; + let git: SimpleGit; let cwdSpy: MockInstance< @@ -120,7 +143,12 @@ describe('runInCI', () => { break; case 'print-config': - let content = await readFile(fixturePaths.config, 'utf8'); + let content = await readFile( + includeUploadConfig + ? fixturePaths.config.portal + : fixturePaths.config.base, + 'utf8', + ); if (nxMatch) { // simulate effect of custom persist.outputDir per Nx project const config = JSON.parse(content) as CoreConfig; @@ -184,6 +212,8 @@ describe('runInCI', () => { const outputDir = path.join(workDir, '.code-pushup', '.ci'); beforeEach(async () => { + includeUploadConfig = false; + const originalExecuteProcess = utils.executeProcess; executeProcessSpy = vi .spyOn(utils, 'executeProcess') @@ -441,6 +471,79 @@ describe('runInCI', () => { expect(logger.debug).toHaveBeenCalled(); }); + it('should use cached old report from portal when upload is configured', async () => { + includeUploadConfig = true; + + const api: ProviderAPIClient = { + maxCommentChars: 1_000_000, + createComment: vi.fn().mockResolvedValue(mockComment), + updateComment: vi.fn(), + listComments: vi.fn().mockResolvedValue([]), + downloadReportArtifact: vi.fn(), + }; + + await expect(runInCI(refs, api, options, git)).resolves.toEqual({ + mode: 'standalone', + commentId: mockComment.id, + newIssues: [], + files: { + current: { + json: path.join(outputDir, '.current/report.json'), + md: path.join(outputDir, '.current/report.md'), + }, + previous: { + json: path.join(outputDir, '.previous/report.json'), + }, + comparison: { + json: path.join(outputDir, '.comparison/report-diff.json'), + md: path.join(outputDir, '.comparison/report-diff.md'), + }, + }, + } satisfies RunResult); + + expect(downloadFromPortal).toHaveBeenCalledWith({ + server: 'https://api.code-pushup.dunder-mifflin.org/graphql', + apiKey: 'cp_abcdef0123456789', + parameters: { + organization: 'dunder-mifflin', + project: 'website', + }, + }); + + expect(api.downloadReportArtifact).not.toHaveBeenCalled(); + + expect(utils.executeProcess).toHaveBeenCalledTimes(3); + expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { + command: options.bin, + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], + cwd: workDir, + observer: expectedObserver, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { + command: options.bin, + args: [], + cwd: workDir, + observer: expectedObserver, + } satisfies utils.ProcessConfig); + expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { + command: options.bin, + args: [ + 'compare', + `--before=${path.join(outputDir, '.previous/report.json')}`, + `--after=${path.join(outputDir, '.current/report.json')}`, + '--persist.format=json', + '--persist.format=md', + ], + cwd: workDir, + observer: expectedObserver, + } satisfies utils.ProcessConfig); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalled(); + }); + it('should skip comment if disabled', async () => { const api: ProviderAPIClient = { maxCommentChars: 1_000_000, diff --git a/packages/core/package.json b/packages/core/package.json index 3310b962e..a69da6568 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,7 +44,7 @@ "ansis": "^3.3.0" }, "peerDependencies": { - "@code-pushup/portal-client": "^0.13.0" + "@code-pushup/portal-client": "^0.14.3" }, "peerDependenciesMeta": { "@code-pushup/portal-client": { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b5cb7596b..7f253b9ec 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,8 +3,10 @@ export { camelCaseToKebabCase, capitalize, kebabCaseToCamelCase, + lowercase, toSentenceCase, toTitleCase, + uppercase, } from './lib/case-conversions.js'; export { filesCoverageToTree, type FileCoverage } from './lib/coverage-tree.js'; export { createRunnerFiles } from './lib/create-runner-files.js'; diff --git a/packages/utils/src/lib/case-conversion.type.test.ts b/packages/utils/src/lib/case-conversion.type.test.ts index 230e59a59..86a69d1c1 100644 --- a/packages/utils/src/lib/case-conversion.type.test.ts +++ b/packages/utils/src/lib/case-conversion.type.test.ts @@ -1,7 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest'; +import { lowercase, uppercase } from './case-conversions.js'; import type { CamelCaseToKebabCase, KebabCaseToCamelCase } from './types.js'; -/* eslint-disable vitest/expect-expect */ describe('CamelCaseToKebabCase', () => { // ✅ CamelCase → kebab-case Type Tests @@ -60,4 +60,37 @@ describe('CamelCaseToKebabCase', () => { assertType>(); }); }); -/* eslint-enable vitest/expect-expect */ + +describe('lowercase', () => { + it('converts string literal to lowercased literal', () => { + expectTypeOf(lowercase('Warning' as const)).toEqualTypeOf<'warning'>(); + }); + + it('converts enum value to lowercased string literal', () => { + enum Severity { + Warning = 'Warning', + } + expectTypeOf(lowercase(Severity.Warning)).toEqualTypeOf<'warning'>(); + }); + + it('converts string to string', () => { + expectTypeOf(lowercase('hello, world')).toBeString(); + }); +}); + +describe('uppercase', () => { + it('converts string literal to uppercased literal', () => { + expectTypeOf(uppercase('Warning' as const)).toEqualTypeOf<'WARNING'>(); + }); + + it('converts enum value to uppercased string literal', () => { + enum Severity { + Warning = 'Warning', + } + expectTypeOf(uppercase(Severity.Warning)).toEqualTypeOf<'WARNING'>(); + }); + + it('converts string to string', () => { + expectTypeOf(uppercase('hello, world')).toBeString(); + }); +}); diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts index 589b65b34..f045f4d5c 100644 --- a/packages/utils/src/lib/case-conversions.ts +++ b/packages/utils/src/lib/case-conversions.ts @@ -97,5 +97,13 @@ export function toSentenceCase(input: string): string { } export function capitalize(text: T): Capitalize { - return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize; + return `${text.charAt(0).toUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize; +} + +export function lowercase(text: T): Lowercase { + return text.toLowerCase() as Lowercase; +} + +export function uppercase(text: T): Uppercase { + return text.toUpperCase() as Uppercase; } diff --git a/packages/utils/src/lib/case-conversions.unit.test.ts b/packages/utils/src/lib/case-conversions.unit.test.ts index 8fdbdbba9..7481efaea 100644 --- a/packages/utils/src/lib/case-conversions.unit.test.ts +++ b/packages/utils/src/lib/case-conversions.unit.test.ts @@ -3,8 +3,10 @@ import { camelCaseToKebabCase, capitalize, kebabCaseToCamelCase, + lowercase, toSentenceCase, toTitleCase, + uppercase, } from './case-conversions.js'; describe('capitalize', () => { @@ -25,6 +27,18 @@ describe('capitalize', () => { }); }); +describe('lowercase', () => { + it('should convert string to lower case', () => { + expect(lowercase('Warning')).toBe('warning'); + }); +}); + +describe('uppercase', () => { + it('should convert string to upper case', () => { + expect(uppercase('Warning')).toBe('WARNING'); + }); +}); + describe('kebabCaseToCamelCase', () => { it('should convert simple kebab-case to camelCase', () => { expect(kebabCaseToCamelCase('hello-world')).toBe('helloWorld'); diff --git a/packages/utils/src/lib/env.unit.test.ts b/packages/utils/src/lib/env.unit.test.ts index 9bff5a54d..c603ad7c8 100644 --- a/packages/utils/src/lib/env.unit.test.ts +++ b/packages/utils/src/lib/env.unit.test.ts @@ -7,6 +7,7 @@ describe('isEnvVarEnabled', () => { }); it('should consider missing variable disabled', () => { + vi.stubEnv('CP_VERBOSE', undefined!); expect(isEnvVarEnabled('CP_VERBOSE')).toBe(false); }); diff --git a/packages/utils/vitest.unit.config.ts b/packages/utils/vitest.unit.config.ts index e6fecfab6..af057c331 100644 --- a/packages/utils/vitest.unit.config.ts +++ b/packages/utils/vitest.unit.config.ts @@ -19,7 +19,10 @@ export default defineConfig({ exclude: ['mocks/**', 'perf/**', '**/types.ts'], }, environment: 'node', - include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + include: ['src/**/*.{unit,type}.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + typecheck: { + include: ['**/*.type.test.ts'], + }, globalSetup: ['../../global-setup.ts'], setupFiles: [ '../../testing/test-setup/src/lib/cliui.mock.ts', diff --git a/testing/test-setup/src/lib/portal-client.mock.ts b/testing/test-setup/src/lib/portal-client.mock.ts index 54dec2d05..db7b6ab1f 100644 --- a/testing/test-setup/src/lib/portal-client.mock.ts +++ b/testing/test-setup/src/lib/portal-client.mock.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; import type { PortalComparisonLinkArgs, PortalUploadArgs, - ReportFragment, + ReportUrlFragment, } from '@code-pushup/portal-client'; vi.mock('@code-pushup/portal-client', async () => { @@ -12,7 +12,7 @@ vi.mock('@code-pushup/portal-client', async () => { return { ...module, uploadToPortal: vi.fn( - async ({ data }: PortalUploadArgs): Promise => ({ + async ({ data }: PortalUploadArgs): Promise => ({ url: `https://code-pushup.example.com/portal/${data.organization}/${data.project}/commit/${data.commit}`, }), ),