mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into 3-18-no-global-loading
This commit is contained in:
commit
a768fc37cf
|
|
@ -4,7 +4,7 @@ runs:
|
|||
using: composite
|
||||
steps:
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@b5d848f5a62488f3d3d920f8aa6ac318a60c5f07 # v1
|
||||
uses: voidzero-dev/setup-vp@4a524139920f87f9f7080d3b8545acac019e1852 # v1.0.0
|
||||
with:
|
||||
node-version-file: "./web/.nvmrc"
|
||||
cache: true
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
close-pr: false
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ jobs:
|
|||
needs: check-changes
|
||||
if: needs.check-changes.outputs.web-changed == 'true'
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
with:
|
||||
base_sha: ${{ github.event.before || github.event.pull_request.base.sha }}
|
||||
diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }}
|
||||
head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }}
|
||||
secrets: inherit
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.detect_changes.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
|
||||
uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1.0.75
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@ name: Web Tests
|
|||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base_sha:
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
type: string
|
||||
diff_range_mode:
|
||||
required: false
|
||||
type: string
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -63,7 +56,7 @@ jobs:
|
|||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VITEST_COVERAGE_SCOPE: app-components
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
|
@ -89,50 +82,14 @@ jobs:
|
|||
- name: Merge reports
|
||||
run: vp test --merge-reports --coverage --silent=passed-only
|
||||
|
||||
- name: Report app/components baseline coverage
|
||||
run: node ./scripts/report-components-coverage-baseline.mjs
|
||||
|
||||
- name: Report app/components test touch
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
run: node ./scripts/report-components-test-touch.mjs
|
||||
|
||||
- name: Check app/components pure diff coverage
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
run: node ./scripts/check-components-diff-coverage.mjs
|
||||
|
||||
- name: Check Coverage Summary
|
||||
if: always()
|
||||
id: coverage-summary
|
||||
run: |
|
||||
set -eo pipefail
|
||||
|
||||
COVERAGE_FILE="coverage/coverage-final.json"
|
||||
COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json"
|
||||
|
||||
if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then
|
||||
echo "has_coverage=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Coverage Artifact
|
||||
if: steps.coverage-summary.outputs.has_coverage == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
name: web-coverage-report
|
||||
path: web/coverage
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
directory: web/coverage
|
||||
flags: web
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||
|
||||
web-build:
|
||||
name: Web Build
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
import {
|
||||
buildGitDiffRevisionArgs,
|
||||
getChangedBranchCoverage,
|
||||
getChangedStatementCoverage,
|
||||
getIgnoredChangedLinesFromSource,
|
||||
normalizeToRepoRelative,
|
||||
parseChangedLineMap,
|
||||
} from '../scripts/check-components-diff-coverage-lib.mjs'
|
||||
|
||||
describe('check-components-diff-coverage helpers', () => {
|
||||
it('should build exact and merge-base git diff revision args', () => {
|
||||
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha'])
|
||||
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha'])
|
||||
})
|
||||
|
||||
it('should parse changed line maps from unified diffs', () => {
|
||||
const diff = [
|
||||
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts',
|
||||
'+++ b/web/app/components/share/a.ts',
|
||||
'@@ -10,0 +11,2 @@',
|
||||
'+const a = 1',
|
||||
'+const b = 2',
|
||||
'diff --git a/web/app/components/base/b.ts b/web/app/components/base/b.ts',
|
||||
'+++ b/web/app/components/base/b.ts',
|
||||
'@@ -20 +21 @@',
|
||||
'+const c = 3',
|
||||
'diff --git a/web/README.md b/web/README.md',
|
||||
'+++ b/web/README.md',
|
||||
'@@ -1 +1 @@',
|
||||
'+ignore me',
|
||||
].join('\n')
|
||||
|
||||
const lineMap = parseChangedLineMap(diff, (filePath: string) => filePath.startsWith('web/app/components/'))
|
||||
|
||||
expect([...lineMap.entries()]).toEqual([
|
||||
['web/app/components/share/a.ts', new Set([11, 12])],
|
||||
['web/app/components/base/b.ts', new Set([21])],
|
||||
])
|
||||
})
|
||||
|
||||
it('should normalize coverage and absolute paths to repo-relative paths', () => {
|
||||
const repoRoot = '/repo'
|
||||
const webRoot = '/repo/web'
|
||||
|
||||
expect(normalizeToRepoRelative('web/app/components/share/a.ts', {
|
||||
appComponentsCoveragePrefix: 'app/components/',
|
||||
appComponentsPrefix: 'web/app/components/',
|
||||
repoRoot,
|
||||
sharedTestPrefix: 'web/__tests__/',
|
||||
webRoot,
|
||||
})).toBe('web/app/components/share/a.ts')
|
||||
|
||||
expect(normalizeToRepoRelative('app/components/share/a.ts', {
|
||||
appComponentsCoveragePrefix: 'app/components/',
|
||||
appComponentsPrefix: 'web/app/components/',
|
||||
repoRoot,
|
||||
sharedTestPrefix: 'web/__tests__/',
|
||||
webRoot,
|
||||
})).toBe('web/app/components/share/a.ts')
|
||||
|
||||
expect(normalizeToRepoRelative('/repo/web/app/components/share/a.ts', {
|
||||
appComponentsCoveragePrefix: 'app/components/',
|
||||
appComponentsPrefix: 'web/app/components/',
|
||||
repoRoot,
|
||||
sharedTestPrefix: 'web/__tests__/',
|
||||
webRoot,
|
||||
})).toBe('web/app/components/share/a.ts')
|
||||
})
|
||||
|
||||
it('should calculate changed statement coverage from changed lines', () => {
|
||||
const entry = {
|
||||
s: { 0: 1, 1: 0 },
|
||||
statementMap: {
|
||||
0: { start: { line: 10 }, end: { line: 10 } },
|
||||
1: { start: { line: 12 }, end: { line: 13 } },
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedStatementCoverage(entry, new Set([10, 12]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 1,
|
||||
total: 2,
|
||||
uncoveredLines: [12],
|
||||
})
|
||||
})
|
||||
|
||||
it('should report the first changed line inside a multi-line uncovered statement', () => {
|
||||
const entry = {
|
||||
s: { 0: 0 },
|
||||
statementMap: {
|
||||
0: { start: { line: 10 }, end: { line: 14 } },
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedStatementCoverage(entry, new Set([13, 14]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 0,
|
||||
total: 1,
|
||||
uncoveredLines: [13],
|
||||
})
|
||||
})
|
||||
|
||||
it('should fail changed lines when a source file has no coverage entry', () => {
|
||||
const coverage = getChangedStatementCoverage(undefined, new Set([42, 43]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 0,
|
||||
total: 2,
|
||||
uncoveredLines: [42, 43],
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate changed branch coverage using changed branch definitions', () => {
|
||||
const entry = {
|
||||
b: {
|
||||
0: [1, 0],
|
||||
},
|
||||
branchMap: {
|
||||
0: {
|
||||
line: 20,
|
||||
loc: { start: { line: 20 }, end: { line: 20 } },
|
||||
locations: [
|
||||
{ start: { line: 20 }, end: { line: 20 } },
|
||||
{ start: { line: 21 }, end: { line: 21 } },
|
||||
],
|
||||
type: 'if',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedBranchCoverage(entry, new Set([20]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 1,
|
||||
total: 2,
|
||||
uncoveredBranches: [
|
||||
{ armIndex: 1, line: 21 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should report the first changed line inside a multi-line uncovered branch arm', () => {
|
||||
const entry = {
|
||||
b: {
|
||||
0: [0, 0],
|
||||
},
|
||||
branchMap: {
|
||||
0: {
|
||||
line: 30,
|
||||
loc: { start: { line: 30 }, end: { line: 35 } },
|
||||
locations: [
|
||||
{ start: { line: 31 }, end: { line: 34 } },
|
||||
{ start: { line: 35 }, end: { line: 38 } },
|
||||
],
|
||||
type: 'if',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedBranchCoverage(entry, new Set([33]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 0,
|
||||
total: 1,
|
||||
uncoveredBranches: [
|
||||
{ armIndex: 0, line: 33 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should require all branch arms when the branch condition changes', () => {
|
||||
const entry = {
|
||||
b: {
|
||||
0: [0, 0],
|
||||
},
|
||||
branchMap: {
|
||||
0: {
|
||||
line: 30,
|
||||
loc: { start: { line: 30 }, end: { line: 35 } },
|
||||
locations: [
|
||||
{ start: { line: 31 }, end: { line: 34 } },
|
||||
{ start: { line: 35 }, end: { line: 38 } },
|
||||
],
|
||||
type: 'if',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedBranchCoverage(entry, new Set([30]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 0,
|
||||
total: 2,
|
||||
uncoveredBranches: [
|
||||
{ armIndex: 0, line: 31 },
|
||||
{ armIndex: 1, line: 35 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore changed lines with valid pragma reasons and report invalid pragmas', () => {
|
||||
const sourceCode = [
|
||||
'const a = 1',
|
||||
'const b = 2 // diff-coverage-ignore-line: defensive fallback',
|
||||
'const c = 3 // diff-coverage-ignore-line:',
|
||||
'const d = 4 // diff-coverage-ignore-line: not changed',
|
||||
].join('\n')
|
||||
|
||||
const result = getIgnoredChangedLinesFromSource(sourceCode, new Set([2, 3]))
|
||||
|
||||
expect([...result.effectiveChangedLines]).toEqual([3])
|
||||
expect([...result.ignoredLines.entries()]).toEqual([
|
||||
[2, 'defensive fallback'],
|
||||
])
|
||||
expect(result.invalidPragmas).toEqual([
|
||||
{ line: 3, reason: 'missing ignore reason' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectComponentCoverageExcludedFiles,
|
||||
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
||||
getComponentCoverageExclusionReasons,
|
||||
} from '../scripts/component-coverage-filters.mjs'
|
||||
|
||||
describe('component coverage filters', () => {
|
||||
describe('getComponentCoverageExclusionReasons', () => {
|
||||
it('should exclude type-only files by basename', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/share/text-generation/types.ts',
|
||||
'export type ShareMode = "run-once" | "run-batch"',
|
||||
),
|
||||
).toContain('type-only')
|
||||
})
|
||||
|
||||
it('should exclude pure barrel files', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/base/amplitude/index.ts',
|
||||
[
|
||||
'export { default } from "./AmplitudeProvider"',
|
||||
'export { resetUser, trackEvent } from "./utils"',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('pure-barrel')
|
||||
})
|
||||
|
||||
it('should exclude generated files from marker comments', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/base/icons/src/vender/workflow/Answer.tsx',
|
||||
[
|
||||
'// GENERATE BY script',
|
||||
'// DON NOT EDIT IT MANUALLY',
|
||||
'export default function Icon() {',
|
||||
' return null',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('generated')
|
||||
})
|
||||
|
||||
it('should exclude pure static files with exported constants only', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/workflow/note-node/constants.ts',
|
||||
[
|
||||
'import { NoteTheme } from "./types"',
|
||||
'export const CUSTOM_NOTE_NODE = "custom-note"',
|
||||
'export const THEME_MAP = {',
|
||||
' [NoteTheme.blue]: { title: "bg-blue-100" },',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('pure-static')
|
||||
})
|
||||
|
||||
it('should keep runtime logic files tracked', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/workflow/nodes/trigger-schedule/default.ts',
|
||||
[
|
||||
'const validate = (value: string) => value.trim()',
|
||||
'export const nodeDefault = {',
|
||||
' value: validate("x"),',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectComponentCoverageExcludedFiles', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs)
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('should collect excluded files for coverage config and keep runtime files out', () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'component-coverage-filters-'))
|
||||
tempDirs.push(rootDir)
|
||||
|
||||
fs.mkdirSync(path.join(rootDir, 'barrel'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'icons'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'static'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'runtime'), { recursive: true })
|
||||
|
||||
fs.writeFileSync(path.join(rootDir, 'barrel', 'index.ts'), 'export { default } from "./Button"\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'icons', 'generated-icon.tsx'), '// @generated\nexport default function Icon() { return null }\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'static', 'constants.ts'), 'export const COLORS = { primary: "#fff" }\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'runtime', 'config.ts'), 'export const config = makeConfig()\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'runtime', 'types.ts'), 'export type Config = { value: string }\n')
|
||||
|
||||
expect(collectComponentCoverageExcludedFiles(rootDir, { pathPrefix: 'app/components' })).toEqual([
|
||||
'app/components/barrel/index.ts',
|
||||
'app/components/icons/generated-icon.tsx',
|
||||
'app/components/runtime/types.ts',
|
||||
'app/components/static/constants.ts',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('should describe the excluded coverage categories', () => {
|
||||
expect(COMPONENT_COVERAGE_EXCLUDE_LABEL).toBe('type-only files, pure barrel files, generated files, pure static files')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import {
|
||||
getCoverageStats,
|
||||
isRelevantTestFile,
|
||||
isTrackedComponentSourceFile,
|
||||
loadTrackedCoverageEntries,
|
||||
} from '../scripts/components-coverage-common.mjs'
|
||||
|
||||
describe('components coverage common helpers', () => {
|
||||
it('should identify tracked component source files and relevant tests', () => {
|
||||
const excludedComponentCoverageFiles = new Set([
|
||||
'web/app/components/share/types.ts',
|
||||
])
|
||||
|
||||
expect(isTrackedComponentSourceFile('web/app/components/share/index.tsx', excludedComponentCoverageFiles)).toBe(true)
|
||||
expect(isTrackedComponentSourceFile('web/app/components/share/types.ts', excludedComponentCoverageFiles)).toBe(false)
|
||||
expect(isTrackedComponentSourceFile('web/app/components/provider/index.tsx', excludedComponentCoverageFiles)).toBe(false)
|
||||
|
||||
expect(isRelevantTestFile('web/__tests__/share/text-generation-run-once-flow.test.tsx')).toBe(true)
|
||||
expect(isRelevantTestFile('web/app/components/share/__tests__/index.spec.tsx')).toBe(true)
|
||||
expect(isRelevantTestFile('web/utils/format.spec.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('should load only tracked coverage entries from mixed coverage paths', () => {
|
||||
const context = {
|
||||
excludedComponentCoverageFiles: new Set([
|
||||
'web/app/components/share/types.ts',
|
||||
]),
|
||||
repoRoot: '/repo',
|
||||
webRoot: '/repo/web',
|
||||
}
|
||||
const coverage = {
|
||||
'/repo/web/app/components/provider/index.tsx': {
|
||||
path: '/repo/web/app/components/provider/index.tsx',
|
||||
statementMap: { 0: { start: { line: 1 }, end: { line: 1 } } },
|
||||
s: { 0: 1 },
|
||||
},
|
||||
'app/components/share/index.tsx': {
|
||||
path: 'app/components/share/index.tsx',
|
||||
statementMap: { 0: { start: { line: 2 }, end: { line: 2 } } },
|
||||
s: { 0: 1 },
|
||||
},
|
||||
'app/components/share/types.ts': {
|
||||
path: 'app/components/share/types.ts',
|
||||
statementMap: { 0: { start: { line: 3 }, end: { line: 3 } } },
|
||||
s: { 0: 1 },
|
||||
},
|
||||
}
|
||||
|
||||
expect([...loadTrackedCoverageEntries(coverage, context).keys()]).toEqual([
|
||||
'web/app/components/share/index.tsx',
|
||||
])
|
||||
})
|
||||
|
||||
it('should calculate coverage stats using statement-derived line hits', () => {
|
||||
const entry = {
|
||||
b: { 0: [1, 0] },
|
||||
f: { 0: 1, 1: 0 },
|
||||
s: { 0: 1, 1: 0 },
|
||||
statementMap: {
|
||||
0: { start: { line: 10 }, end: { line: 10 } },
|
||||
1: { start: { line: 12 }, end: { line: 13 } },
|
||||
},
|
||||
}
|
||||
|
||||
expect(getCoverageStats(entry)).toEqual({
|
||||
branches: { covered: 1, total: 2 },
|
||||
functions: { covered: 1, total: 2 },
|
||||
lines: { covered: 1, total: 2 },
|
||||
statements: { covered: 1, total: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '../index'
|
||||
|
||||
const renderScrollArea = (options: {
|
||||
rootClassName?: string
|
||||
viewportClassName?: string
|
||||
verticalScrollbarClassName?: string
|
||||
horizontalScrollbarClassName?: string
|
||||
verticalThumbClassName?: string
|
||||
horizontalThumbClassName?: string
|
||||
} = {}) => {
|
||||
return render(
|
||||
<ScrollArea className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root">
|
||||
<ScrollAreaViewport data-testid="scroll-area-viewport" className={options.viewportClassName}>
|
||||
<ScrollAreaContent data-testid="scroll-area-content">
|
||||
<div className="h-48 w-48">Scrollable content</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar
|
||||
keepMounted
|
||||
data-testid="scroll-area-vertical-scrollbar"
|
||||
className={options.verticalScrollbarClassName}
|
||||
>
|
||||
<ScrollAreaThumb data-testid="scroll-area-vertical-thumb" className={options.verticalThumbClassName} />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar
|
||||
keepMounted
|
||||
orientation="horizontal"
|
||||
data-testid="scroll-area-horizontal-scrollbar"
|
||||
className={options.horizontalScrollbarClassName}
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-testid="scroll-area-horizontal-thumb"
|
||||
className={options.horizontalThumbClassName}
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('scroll-area wrapper', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the compound exports together', async () => {
|
||||
renderScrollArea()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('scroll-area-root')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scroll-area-viewport')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scroll-area-content')).toHaveTextContent('Scrollable content')
|
||||
expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scroll-area-vertical-thumb')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scrollbar', () => {
|
||||
it('should apply the default vertical scrollbar classes and orientation data attribute', async () => {
|
||||
renderScrollArea()
|
||||
|
||||
await waitFor(() => {
|
||||
const scrollbar = screen.getByTestId('scroll-area-vertical-scrollbar')
|
||||
const thumb = screen.getByTestId('scroll-area-vertical-thumb')
|
||||
|
||||
expect(scrollbar).toHaveAttribute('data-orientation', 'vertical')
|
||||
expect(scrollbar).toHaveClass(
|
||||
'flex',
|
||||
'touch-none',
|
||||
'select-none',
|
||||
'opacity-0',
|
||||
'transition-opacity',
|
||||
'motion-reduce:transition-none',
|
||||
'pointer-events-none',
|
||||
'data-[hovering]:pointer-events-auto',
|
||||
'data-[hovering]:opacity-100',
|
||||
'data-[scrolling]:pointer-events-auto',
|
||||
'data-[scrolling]:opacity-100',
|
||||
'hover:pointer-events-auto',
|
||||
'hover:opacity-100',
|
||||
'data-[orientation=vertical]:absolute',
|
||||
'data-[orientation=vertical]:inset-y-0',
|
||||
'data-[orientation=vertical]:right-0',
|
||||
'data-[orientation=vertical]:w-3',
|
||||
'data-[orientation=vertical]:justify-center',
|
||||
)
|
||||
expect(thumb).toHaveAttribute('data-orientation', 'vertical')
|
||||
expect(thumb).toHaveClass(
|
||||
'shrink-0',
|
||||
'rounded-[4px]',
|
||||
'bg-state-base-handle',
|
||||
'transition-[background-color]',
|
||||
'hover:bg-state-base-handle-hover',
|
||||
'motion-reduce:transition-none',
|
||||
'data-[orientation=vertical]:w-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply horizontal scrollbar and thumb classes when orientation is horizontal', async () => {
|
||||
renderScrollArea()
|
||||
|
||||
await waitFor(() => {
|
||||
const scrollbar = screen.getByTestId('scroll-area-horizontal-scrollbar')
|
||||
const thumb = screen.getByTestId('scroll-area-horizontal-thumb')
|
||||
|
||||
expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal')
|
||||
expect(scrollbar).toHaveClass(
|
||||
'flex',
|
||||
'touch-none',
|
||||
'select-none',
|
||||
'opacity-0',
|
||||
'transition-opacity',
|
||||
'motion-reduce:transition-none',
|
||||
'pointer-events-none',
|
||||
'data-[hovering]:pointer-events-auto',
|
||||
'data-[hovering]:opacity-100',
|
||||
'data-[scrolling]:pointer-events-auto',
|
||||
'data-[scrolling]:opacity-100',
|
||||
'hover:pointer-events-auto',
|
||||
'hover:opacity-100',
|
||||
'data-[orientation=horizontal]:absolute',
|
||||
'data-[orientation=horizontal]:inset-x-0',
|
||||
'data-[orientation=horizontal]:bottom-0',
|
||||
'data-[orientation=horizontal]:h-3',
|
||||
'data-[orientation=horizontal]:items-center',
|
||||
)
|
||||
expect(thumb).toHaveAttribute('data-orientation', 'horizontal')
|
||||
expect(thumb).toHaveClass(
|
||||
'shrink-0',
|
||||
'rounded-[4px]',
|
||||
'bg-state-base-handle',
|
||||
'transition-[background-color]',
|
||||
'hover:bg-state-base-handle-hover',
|
||||
'motion-reduce:transition-none',
|
||||
'data-[orientation=horizontal]:h-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should forward className to the viewport', async () => {
|
||||
renderScrollArea({
|
||||
viewportClassName: 'custom-viewport-class',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('scroll-area-viewport')).toHaveClass(
|
||||
'size-full',
|
||||
'min-h-0',
|
||||
'min-w-0',
|
||||
'outline-none',
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-inset',
|
||||
'focus-visible:ring-components-input-border-hover',
|
||||
'custom-viewport-class',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Corner', () => {
|
||||
it('should render the corner export when both axes overflow', async () => {
|
||||
const originalDescriptors = {
|
||||
clientHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight'),
|
||||
clientWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientWidth'),
|
||||
scrollHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollHeight'),
|
||||
scrollWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollWidth'),
|
||||
}
|
||||
|
||||
Object.defineProperties(HTMLDivElement.prototype, {
|
||||
clientHeight: {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0
|
||||
},
|
||||
},
|
||||
clientWidth: {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0
|
||||
},
|
||||
},
|
||||
scrollHeight: {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0
|
||||
},
|
||||
},
|
||||
scrollWidth: {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
render(
|
||||
<ScrollArea className="h-40 w-40" data-testid="scroll-area-root">
|
||||
<ScrollAreaViewport data-testid="scroll-area-viewport">
|
||||
<ScrollAreaContent data-testid="scroll-area-content">
|
||||
<div className="h-48 w-48">Scrollable content</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar keepMounted data-testid="scroll-area-vertical-scrollbar">
|
||||
<ScrollAreaThumb data-testid="scroll-area-vertical-thumb" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar
|
||||
keepMounted
|
||||
orientation="horizontal"
|
||||
data-testid="scroll-area-horizontal-scrollbar"
|
||||
>
|
||||
<ScrollAreaThumb data-testid="scroll-area-horizontal-thumb" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner data-testid="scroll-area-corner" />
|
||||
</ScrollArea>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('scroll-area-corner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scroll-area-corner')).toHaveClass('bg-transparent')
|
||||
})
|
||||
}
|
||||
finally {
|
||||
if (originalDescriptors.clientHeight) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalDescriptors.clientHeight)
|
||||
}
|
||||
if (originalDescriptors.clientWidth) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, 'clientWidth', originalDescriptors.clientWidth)
|
||||
}
|
||||
if (originalDescriptors.scrollHeight) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', originalDescriptors.scrollHeight)
|
||||
}
|
||||
if (originalDescriptors.scrollWidth) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, 'scrollWidth', originalDescriptors.scrollWidth)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,563 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { ReactNode } from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Layout/ScrollArea',
|
||||
component: ScrollArea,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound scroll container built on Base UI ScrollArea. These stories focus on panel-style compositions that already exist throughout Dify: dense sidebars, sticky list headers, multi-pane workbenches, horizontal rails, and overlay surfaces.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ScrollArea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const panelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'
|
||||
const blurPanelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl shadow-shadow-shadow-7 backdrop-blur-[6px]'
|
||||
const labelClassName = 'text-text-tertiary system-xs-medium-uppercase tracking-[0.14em]'
|
||||
const titleClassName = 'text-text-primary system-sm-semibold'
|
||||
const bodyClassName = 'text-text-secondary system-sm-regular'
|
||||
const insetScrollAreaClassName = 'h-full p-1'
|
||||
const insetViewportClassName = 'rounded-[20px] bg-components-panel-bg'
|
||||
const insetScrollbarClassName = 'data-[orientation=vertical]:top-1 data-[orientation=vertical]:bottom-1 data-[orientation=vertical]:right-1 data-[orientation=horizontal]:bottom-1 data-[orientation=horizontal]:left-1 data-[orientation=horizontal]:right-1'
|
||||
const storyButtonClassName = 'flex w-full items-center justify-between gap-3 rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-2.5 text-left text-text-secondary transition-colors hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none'
|
||||
const sidebarScrollAreaClassName = 'h-full pr-2'
|
||||
const sidebarViewportClassName = 'overscroll-contain pr-2'
|
||||
const sidebarContentClassName = 'space-y-0.5 pr-2'
|
||||
const sidebarScrollbarClassName = 'data-[orientation=vertical]:right-0.5'
|
||||
const appNavButtonClassName = 'group flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none'
|
||||
const appNavMetaClassName = 'shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 text-text-quaternary system-2xs-medium-uppercase tracking-[0.08em]'
|
||||
|
||||
const releaseRows = [
|
||||
{ title: 'Agent refactor', meta: 'Updated 2 hours ago', status: 'Ready' },
|
||||
{ title: 'Retriever tuning', meta: 'Updated yesterday', status: 'Review' },
|
||||
{ title: 'Workflow replay', meta: 'Updated 3 days ago', status: 'Draft' },
|
||||
{ title: 'Sandbox policy', meta: 'Updated this week', status: 'Ready' },
|
||||
{ title: 'SSE diagnostics', meta: 'Updated last week', status: 'Blocked' },
|
||||
{ title: 'Model routing', meta: 'Updated 9 days ago', status: 'Review' },
|
||||
{ title: 'Chunk overlap', meta: 'Updated 11 days ago', status: 'Draft' },
|
||||
{ title: 'Vector warmup', meta: 'Updated 2 weeks ago', status: 'Ready' },
|
||||
] as const
|
||||
|
||||
const queueRows = [
|
||||
{ id: 'PLG-142', title: 'Plugin catalog sync', note: 'Waiting for moderation result' },
|
||||
{ id: 'OPS-088', title: 'Billing alert fallback', note: 'Last retry finished 12 minutes ago' },
|
||||
{ id: 'RAG-511', title: 'Embedding migration', note: '16 datasets still pending' },
|
||||
{ id: 'AGT-204', title: 'Multi-agent tracing', note: 'QA is verifying edge cases' },
|
||||
{ id: 'UI-390', title: 'Prompt editor polish', note: 'Needs token density pass' },
|
||||
{ id: 'WEB-072', title: 'Marketplace empty state', note: 'Waiting for design review' },
|
||||
] as const
|
||||
|
||||
const horizontalCards = [
|
||||
{ title: 'Claude Opus', detail: 'Reasoning-heavy preset' },
|
||||
{ title: 'GPT-5.4', detail: 'Balanced orchestration lane' },
|
||||
{ title: 'Gemini 2.5', detail: 'Multimodal fallback' },
|
||||
{ title: 'Qwen Max', detail: 'Regional deployment' },
|
||||
{ title: 'DeepSeek R1', detail: 'High-throughput analysis' },
|
||||
{ title: 'Llama 4', detail: 'Cost-sensitive routing' },
|
||||
] as const
|
||||
|
||||
const activityRows = Array.from({ length: 14 }, (_, index) => ({
|
||||
title: `Workspace activity ${index + 1}`,
|
||||
body: 'A short line of copy to mimic dense operational feeds in settings and debug panels.',
|
||||
}))
|
||||
|
||||
const webAppsRows = [
|
||||
{ id: 'invoice-copilot', name: 'Invoice Copilot', meta: 'Pinned', icon: '🧾', iconBackground: '#FFEAD5', selected: true, pinned: true },
|
||||
{ id: 'rag-ops', name: 'RAG Ops Console', meta: 'Ops', icon: '🛰️', iconBackground: '#E0F2FE', selected: false, pinned: true },
|
||||
{ id: 'knowledge-studio', name: 'Knowledge Studio', meta: 'Docs', icon: '📚', iconBackground: '#FEF3C7', selected: false, pinned: true },
|
||||
{ id: 'workflow-studio', name: 'Workflow Studio', meta: 'Build', icon: '🧩', iconBackground: '#E0E7FF', selected: false, pinned: true },
|
||||
{ id: 'growth-briefs', name: 'Growth Briefs', meta: 'Brief', icon: '📣', iconBackground: '#FCE7F3', selected: false, pinned: true },
|
||||
{ id: 'agent-playground', name: 'Agent Playground', meta: 'Lab', icon: '🧪', iconBackground: '#DCFCE7', selected: false, pinned: false },
|
||||
{ id: 'sales-briefing', name: 'Sales Briefing', meta: 'Team', icon: '📈', iconBackground: '#FCE7F3', selected: false, pinned: false },
|
||||
{ id: 'support-triage', name: 'Support Triage', meta: 'Queue', icon: '🎧', iconBackground: '#EDE9FE', selected: false, pinned: false },
|
||||
{ id: 'legal-review', name: 'Legal Review', meta: 'Beta', icon: '⚖️', iconBackground: '#FDE68A', selected: false, pinned: false },
|
||||
{ id: 'release-watcher', name: 'Release Watcher', meta: 'Feed', icon: '🚀', iconBackground: '#DBEAFE', selected: false, pinned: false },
|
||||
{ id: 'research-hub', name: 'Research Hub', meta: 'Notes', icon: '🔎', iconBackground: '#E0F2FE', selected: false, pinned: false },
|
||||
{ id: 'field-enablement', name: 'Field Enablement', meta: 'Team', icon: '🧭', iconBackground: '#DCFCE7', selected: false, pinned: false },
|
||||
{ id: 'brand-monitor', name: 'Brand Monitor', meta: 'Watch', icon: '🪄', iconBackground: '#F3E8FF', selected: false, pinned: false },
|
||||
{ id: 'finance-ops', name: 'Finance Ops Desk', meta: 'Ops', icon: '💳', iconBackground: '#FEF3C7', selected: false, pinned: false },
|
||||
{ id: 'security-radar', name: 'Security Radar', meta: 'Risk', icon: '🛡️', iconBackground: '#FEE2E2', selected: false, pinned: false },
|
||||
{ id: 'partner-portal', name: 'Partner Portal', meta: 'Ext', icon: '🤝', iconBackground: '#DBEAFE', selected: false, pinned: false },
|
||||
{ id: 'qa-replays', name: 'QA Replays', meta: 'Debug', icon: '🎞️', iconBackground: '#EDE9FE', selected: false, pinned: false },
|
||||
{ id: 'roadmap-notes', name: 'Roadmap Notes', meta: 'Plan', icon: '🗺️', iconBackground: '#FFEAD5', selected: false, pinned: false },
|
||||
] as const
|
||||
|
||||
const StoryCard = ({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<section className={cn('min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5', className)}>
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>{eyebrow}</div>
|
||||
<h3 className="text-pretty text-text-primary system-md-semibold">{title}</h3>
|
||||
<p className="max-w-[72ch] text-pretty text-text-secondary system-sm-regular">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
|
||||
const VerticalPanelPane = () => (
|
||||
<div className={cn(panelClassName, 'h-[360px]')}>
|
||||
<ScrollArea className={insetScrollAreaClassName}>
|
||||
<ScrollAreaViewport className={insetViewportClassName}>
|
||||
<ScrollAreaContent className="space-y-3 p-4 pr-6">
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>Release board</div>
|
||||
<div className="text-text-primary system-md-semibold">Weekly checkpoints</div>
|
||||
<p className={bodyClassName}>A simple vertical panel with the default scrollbar skin and no business-specific overrides.</p>
|
||||
</div>
|
||||
{releaseRows.map(item => (
|
||||
<article key={item.title} className="rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h4 className={cn(titleClassName, 'truncate')}>{item.title}</h4>
|
||||
<p className="text-text-tertiary system-xs-regular">{item.meta}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-state-base-hover px-2 py-1 text-text-secondary system-xs-medium">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
const StickyListPane = () => (
|
||||
<div className={cn(panelClassName, 'h-[360px]')}>
|
||||
<ScrollArea className={insetScrollAreaClassName}>
|
||||
<ScrollAreaViewport className={cn(insetViewportClassName, '[mask-image:linear-gradient(to_bottom,transparent_0px,black_10px,black_calc(100%-14px),transparent_100%)]')}>
|
||||
<ScrollAreaContent className="min-h-full">
|
||||
<div className="sticky top-0 z-10 border-b border-divider-subtle bg-components-panel-bg px-4 pb-3 pt-4">
|
||||
<div className={labelClassName}>Sticky header</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-md-semibold">Operational queue</div>
|
||||
<p className="mt-1 text-text-secondary system-xs-regular">The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.</p>
|
||||
</div>
|
||||
<span className="rounded-lg border border-divider-subtle bg-components-panel-bg-alt px-2.5 py-1 text-text-secondary system-xs-medium">
|
||||
24 items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 px-4 py-3 pr-6">
|
||||
{queueRows.map(item => (
|
||||
<article key={item.id} className="rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="truncate text-text-primary system-sm-semibold">{item.title}</div>
|
||||
<div className="line-clamp-2 break-words text-text-tertiary system-xs-regular">{item.note}</div>
|
||||
</div>
|
||||
<span className="text-text-quaternary system-xs-medium">{item.id}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
const WorkbenchPane = ({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
eyebrow: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div className={cn(panelClassName, 'min-h-0', className)}>
|
||||
<ScrollArea className={insetScrollAreaClassName}>
|
||||
<ScrollAreaViewport className={insetViewportClassName}>
|
||||
<ScrollAreaContent className="space-y-3 p-4 pr-6">
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>{eyebrow}</div>
|
||||
<div className="text-text-primary system-md-semibold">{title}</div>
|
||||
</div>
|
||||
{children}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
const HorizontalRailPane = () => (
|
||||
<div className={cn(panelClassName, 'h-[272px] min-w-0 max-w-full')}>
|
||||
<ScrollArea className={insetScrollAreaClassName}>
|
||||
<ScrollAreaViewport className={insetViewportClassName}>
|
||||
<ScrollAreaContent className="min-h-full min-w-max space-y-4 p-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>Horizontal rail</div>
|
||||
<div className="text-text-primary system-md-semibold">Model lanes</div>
|
||||
<p className={bodyClassName}>This pane keeps the default track behavior and only changes the surface layout around it.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{horizontalCards.map(card => (
|
||||
<article key={card.title} className="flex h-[152px] w-[232px] shrink-0 flex-col justify-between rounded-2xl border border-divider-subtle bg-components-panel-bg-alt p-4">
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-state-base-hover text-text-secondary">
|
||||
<span aria-hidden className="i-ri-stack-line size-5" />
|
||||
</span>
|
||||
<div className="text-text-primary system-sm-semibold">{card.title}</div>
|
||||
<div className="text-text-secondary system-sm-regular">{card.detail}</div>
|
||||
</div>
|
||||
<div className="text-text-tertiary system-xs-regular">Drag cards into orchestration groups.</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
const OverlayPane = () => (
|
||||
<div className="flex h-[420px] min-w-0 items-center justify-center rounded-[28px] bg-[radial-gradient(circle_at_top,_rgba(21,90,239,0.12),_transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.03),transparent)] p-6">
|
||||
<div className={cn(blurPanelClassName, 'w-full max-w-[360px]')}>
|
||||
<ScrollArea className="h-[320px] p-1">
|
||||
<ScrollAreaViewport className="overscroll-contain rounded-[20px] bg-components-panel-bg-blur">
|
||||
<ScrollAreaContent className="space-y-2 p-3 pr-6">
|
||||
<div className="sticky top-0 z-10 rounded-xl border border-divider-subtle bg-components-panel-bg-blur px-3 py-3 backdrop-blur-[6px]">
|
||||
<div className={labelClassName}>Overlay palette</div>
|
||||
<div className="mt-1 text-text-primary system-md-semibold">Quick actions</div>
|
||||
</div>
|
||||
{activityRows.map(item => (
|
||||
<article key={item.title} className="rounded-xl border border-divider-subtle bg-components-panel-bg px-3 py-3 shadow-sm shadow-shadow-shadow-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-state-base-hover text-text-secondary">
|
||||
<span aria-hidden className="i-ri-flashlight-line size-4" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="text-text-primary system-sm-semibold">{item.title}</div>
|
||||
<div className="text-text-secondary system-xs-regular">{item.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb className="rounded-full bg-state-base-handle hover:bg-state-base-handle-hover" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CornerPane = () => (
|
||||
<div className={cn(panelClassName, 'h-[320px] w-full max-w-[440px]')}>
|
||||
<ScrollArea className={cn(insetScrollAreaClassName, 'overflow-hidden')}>
|
||||
<ScrollAreaViewport className={cn(insetViewportClassName, 'bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]')}>
|
||||
<ScrollAreaContent className="min-h-[420px] min-w-[620px] space-y-4 p-4">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>Corner surface</div>
|
||||
<div className="text-text-primary system-md-semibold">Bi-directional inspector canvas</div>
|
||||
<p className={bodyClassName}>Both axes overflow here so the corner becomes visible as a deliberate seam between the two tracks.</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-state-base-hover px-2 py-1 text-text-secondary system-xs-medium">
|
||||
Always visible
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid min-w-[560px] grid-cols-[220px_repeat(3,180px)] gap-3">
|
||||
{Array.from({ length: 12 }, (_, index) => (
|
||||
<article key={index} className="rounded-2xl border border-divider-subtle bg-components-panel-bg-alt p-4">
|
||||
<div className="text-text-primary system-sm-semibold">
|
||||
Cell
|
||||
{' '}
|
||||
{index + 1}
|
||||
</div>
|
||||
<p className="mt-2 text-text-secondary system-sm-regular">
|
||||
Wide-and-tall content to force both scrollbars and show the corner treatment clearly.
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner className="bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ExploreSidebarWebAppsPane = () => {
|
||||
const pinnedAppsCount = webAppsRows.filter(item => item.pinned).length
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[272px] rounded-[26px] border border-divider-subtle bg-background-body p-3 shadow-lg shadow-shadow-shadow-5">
|
||||
<div className="space-y-5 rounded-[20px] bg-background-default-subtle p-3">
|
||||
<div className="text-text-accent">
|
||||
<div className="flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2">
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
|
||||
<span className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-components-menu-item-text-active system-sm-semibold">
|
||||
Explore
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 px-2">
|
||||
<p className="min-w-0 uppercase text-text-tertiary system-xs-medium-uppercase">
|
||||
Web Apps
|
||||
</p>
|
||||
<span className="shrink-0 text-text-quaternary system-xs-medium">
|
||||
{webAppsRows.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-[304px]">
|
||||
<ScrollArea className={sidebarScrollAreaClassName}>
|
||||
<ScrollAreaViewport className={sidebarViewportClassName}>
|
||||
<ScrollAreaContent className={sidebarContentClassName}>
|
||||
{webAppsRows.map((item, index) => (
|
||||
<div key={item.id} className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
appNavButtonClassName,
|
||||
item.selected
|
||||
? 'bg-state-base-active text-components-menu-item-text-active'
|
||||
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 grow items-center gap-2">
|
||||
<AppIcon
|
||||
size="tiny"
|
||||
iconType="emoji"
|
||||
icon={item.icon}
|
||||
background={item.iconBackground}
|
||||
/>
|
||||
<span className="min-w-0 truncate system-sm-regular">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
appNavMetaClassName,
|
||||
item.selected && 'border-transparent bg-state-accent-hover text-text-accent',
|
||||
)}
|
||||
>
|
||||
{item.meta}
|
||||
</span>
|
||||
</button>
|
||||
{index === pinnedAppsCount - 1 && index !== webAppsRows.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={sidebarScrollbarClassName}>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerticalPanels: Story = {
|
||||
render: () => (
|
||||
<StoryCard
|
||||
eyebrow="Panels"
|
||||
title="Default and extended vertical panes"
|
||||
description="Two common Dify surfaces: a straightforward content panel using the shipped scrollbar skin, and a denser queue pane that adds sticky structure, a viewport mask, and a slightly inset scrollbar."
|
||||
>
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<VerticalPanelPane />
|
||||
<StickyListPane />
|
||||
</div>
|
||||
</StoryCard>
|
||||
),
|
||||
}
|
||||
|
||||
export const ThreePaneWorkbench: Story = {
|
||||
render: () => (
|
||||
<StoryCard
|
||||
eyebrow="Workbench"
|
||||
title="Multi-pane composition"
|
||||
description="A three-pane arrangement that mirrors settings and workflow layouts. Each pane uses the same base compound API, but the surfaces and content density differ."
|
||||
>
|
||||
<div className="grid h-[520px] gap-4 xl:grid-cols-[260px_minmax(0,1fr)_320px]">
|
||||
<WorkbenchPane title="Collections" eyebrow="Left rail">
|
||||
<div className="space-y-2">
|
||||
{releaseRows.map(item => (
|
||||
<button key={item.title} type="button" className={storyButtonClassName}>
|
||||
<span className="min-w-0 truncate system-sm-medium">{item.title}</span>
|
||||
<span className="text-text-quaternary system-xs-medium">{item.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</WorkbenchPane>
|
||||
<WorkbenchPane title="Pipeline detail" eyebrow="Center pane" className="bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 7 }, (_, index) => (
|
||||
<section key={index} className="rounded-2xl border border-divider-subtle bg-components-panel-bg-alt p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-text-primary system-sm-semibold">
|
||||
Section
|
||||
{' '}
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="rounded-full bg-state-base-hover px-2 py-1 text-text-secondary system-xs-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-text-secondary system-sm-regular">
|
||||
This pane is intentionally long so the default vertical scrollbar sits over a larger editorial surface.
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</WorkbenchPane>
|
||||
<WorkbenchPane title="Inspector" eyebrow="Right rail">
|
||||
<div className="space-y-3">
|
||||
{queueRows.map(item => (
|
||||
<article key={item.id} className="rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-3">
|
||||
<div className="text-text-primary system-sm-semibold">{item.id}</div>
|
||||
<div className="mt-1 text-text-secondary system-sm-regular">{item.title}</div>
|
||||
<div className="mt-2 text-text-tertiary system-xs-regular">{item.note}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</WorkbenchPane>
|
||||
</div>
|
||||
</StoryCard>
|
||||
),
|
||||
}
|
||||
|
||||
export const HorizontalAndOverlay: Story = {
|
||||
render: () => (
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|
||||
<StoryCard
|
||||
eyebrow="Horizontal"
|
||||
title="Scrollable rails"
|
||||
description="A horizontal lane with cards wider than the viewport. The story keeps the shared base scrollbar and only shifts its placement slightly for a cleaner presentation."
|
||||
>
|
||||
<HorizontalRailPane />
|
||||
</StoryCard>
|
||||
<StoryCard
|
||||
eyebrow="Overlay"
|
||||
title="Popup and blurred surfaces"
|
||||
description="An overlay-style surface that mirrors menus, pickers, and sidecar drawers already present in the app. The business layer only adjusts the shell and thumb rounding."
|
||||
>
|
||||
<OverlayPane />
|
||||
</StoryCard>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CornerSurface: Story = {
|
||||
render: () => (
|
||||
<StoryCard
|
||||
eyebrow="Corner"
|
||||
title="Explicit corner treatment"
|
||||
description="This example keeps both tracks visible so the bottom-right corner can be inspected as part of the surface design, not as an accidental leftover."
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<CornerPane />
|
||||
</div>
|
||||
</StoryCard>
|
||||
),
|
||||
}
|
||||
|
||||
export const ExploreSidebarWebApps: Story = {
|
||||
render: () => (
|
||||
<StoryCard
|
||||
eyebrow="Explore"
|
||||
title="Web apps sidebar list"
|
||||
description="A sidebar-style pane modeled after /explore/apps. The story keeps the shared ScrollArea primitive and composes the surrounding shell, section label, selected state, and pinned divider at the story layer."
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<ExploreSidebarWebAppsPane />
|
||||
</div>
|
||||
</StoryCard>
|
||||
),
|
||||
}
|
||||
|
||||
export const PrimitiveComposition: Story = {
|
||||
render: () => (
|
||||
<StoryCard
|
||||
eyebrow="Primitive"
|
||||
title="Minimal composition reference"
|
||||
description="A stripped-down example for teams that want to start from the base API and add their own shell classes around it. The outer shell adds inset padding so the tracks sit inside the rounded surface instead of colliding with the panel corners."
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-[260px] max-w-[420px]')}>
|
||||
<ScrollArea className={insetScrollAreaClassName}>
|
||||
<ScrollAreaViewport className={insetViewportClassName}>
|
||||
<ScrollAreaContent className="min-w-[560px] space-y-3 p-4 pr-6">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<div key={index} className="rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-3 text-text-secondary system-sm-regular">
|
||||
Primitive row
|
||||
{' '}
|
||||
{index + 1}
|
||||
</div>
|
||||
))}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</StoryCard>
|
||||
),
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
|
||||
import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const ScrollArea = BaseScrollArea.Root
|
||||
export type ScrollAreaRootProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Root>
|
||||
|
||||
export const ScrollAreaContent = BaseScrollArea.Content
|
||||
export type ScrollAreaContentProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Content>
|
||||
|
||||
export const scrollAreaScrollbarClassName = cn(
|
||||
'flex touch-none select-none opacity-0 transition-opacity motion-reduce:transition-none',
|
||||
'pointer-events-none data-[hovering]:pointer-events-auto data-[hovering]:opacity-100',
|
||||
'data-[scrolling]:pointer-events-auto data-[scrolling]:opacity-100',
|
||||
'hover:pointer-events-auto hover:opacity-100',
|
||||
'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:right-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center',
|
||||
'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:bottom-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center',
|
||||
)
|
||||
|
||||
export const scrollAreaThumbClassName = cn(
|
||||
'shrink-0 rounded-[4px] bg-state-base-handle transition-[background-color] hover:bg-state-base-handle-hover motion-reduce:transition-none',
|
||||
'data-[orientation=vertical]:w-1',
|
||||
'data-[orientation=horizontal]:h-1',
|
||||
)
|
||||
|
||||
export const scrollAreaViewportClassName = cn(
|
||||
'size-full min-h-0 min-w-0 outline-none',
|
||||
'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover',
|
||||
)
|
||||
|
||||
export const scrollAreaCornerClassName = 'bg-transparent'
|
||||
|
||||
export type ScrollAreaViewportProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Viewport>
|
||||
|
||||
export function ScrollAreaViewport({
|
||||
className,
|
||||
...props
|
||||
}: ScrollAreaViewportProps) {
|
||||
return (
|
||||
<BaseScrollArea.Viewport
|
||||
className={cn(scrollAreaViewportClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ScrollAreaScrollbarProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Scrollbar>
|
||||
|
||||
export function ScrollAreaScrollbar({
|
||||
className,
|
||||
...props
|
||||
}: ScrollAreaScrollbarProps) {
|
||||
return (
|
||||
<BaseScrollArea.Scrollbar
|
||||
className={cn(scrollAreaScrollbarClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ScrollAreaThumbProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Thumb>
|
||||
|
||||
export function ScrollAreaThumb({
|
||||
className,
|
||||
...props
|
||||
}: ScrollAreaThumbProps) {
|
||||
return (
|
||||
<BaseScrollArea.Thumb
|
||||
className={cn(scrollAreaThumbClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ScrollAreaCornerProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Corner>
|
||||
|
||||
export function ScrollAreaCorner({
|
||||
className,
|
||||
...props
|
||||
}: ScrollAreaCornerProps) {
|
||||
return (
|
||||
<BaseScrollArea.Corner
|
||||
className={cn(scrollAreaCornerClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -281,8 +281,7 @@ Thought: {{agent_scratchpad}}
|
|||
`,
|
||||
}
|
||||
|
||||
export const VAR_REGEX
|
||||
= /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi
|
||||
export const VAR_REGEX = /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi
|
||||
|
||||
export const resetReg = () => (VAR_REGEX.lastIndex = 0)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import tailwindcss from 'eslint-plugin-better-tailwindcss'
|
|||
import hyoban from 'eslint-plugin-hyoban'
|
||||
import sonar from 'eslint-plugin-sonarjs'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import { OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs'
|
||||
import {
|
||||
HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS,
|
||||
NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
|
||||
NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
|
||||
OVERLAY_MIGRATION_LEGACY_BASE_FILES,
|
||||
OVERLAY_RESTRICTED_IMPORT_PATTERNS,
|
||||
} from './eslint.constants.mjs'
|
||||
import dify from './plugins/eslint/index.js'
|
||||
|
||||
// Enable Tailwind CSS IntelliSense mode for ESLint runs
|
||||
|
|
@ -14,99 +20,6 @@ process.env.TAILWIND_MODE ??= 'ESLINT'
|
|||
|
||||
const disableRuleAutoFix = !(isInEditorEnv() || isInGitHooksOrLintStaged())
|
||||
|
||||
const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [
|
||||
{
|
||||
name: 'next',
|
||||
message: 'Import Next APIs from the corresponding @/next module instead of next.',
|
||||
},
|
||||
]
|
||||
|
||||
const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
|
||||
{
|
||||
group: ['next/image'],
|
||||
message: 'Do not import next/image. Use native img tags instead.',
|
||||
},
|
||||
{
|
||||
group: ['next/font', 'next/font/*'],
|
||||
message: 'Do not import next/font. Use the project font styles instead.',
|
||||
},
|
||||
{
|
||||
group: ['next/*', '!next/font', '!next/font/*', '!next/image', '!next/image/*'],
|
||||
message: 'Import Next APIs from the corresponding @/next/* module instead of next/*.',
|
||||
},
|
||||
]
|
||||
|
||||
const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
||||
{
|
||||
group: [
|
||||
'**/portal-to-follow-elem',
|
||||
'**/portal-to-follow-elem/index',
|
||||
],
|
||||
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/tooltip',
|
||||
'**/base/tooltip/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/modal',
|
||||
'**/base/modal/index',
|
||||
'**/base/modal/modal',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/select',
|
||||
'**/base/select/index',
|
||||
'**/base/select/custom',
|
||||
'**/base/select/pure',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/confirm',
|
||||
'**/base/confirm/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/popover',
|
||||
'**/base/popover/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/dropdown',
|
||||
'**/base/dropdown/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/dialog',
|
||||
'**/base/dialog/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/toast',
|
||||
'**/base/toast/index',
|
||||
'**/base/toast/context',
|
||||
'**/base/toast/context/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.',
|
||||
},
|
||||
]
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
react: {
|
||||
|
|
@ -192,37 +105,7 @@ export default antfu(
|
|||
{
|
||||
files: ['**/*.tsx'],
|
||||
rules: {
|
||||
'hyoban/prefer-tailwind-icons': ['warn', {
|
||||
prefix: 'i-',
|
||||
propMappings: {
|
||||
size: 'size',
|
||||
width: 'w',
|
||||
height: 'h',
|
||||
},
|
||||
libraries: [
|
||||
{
|
||||
prefix: 'i-custom-',
|
||||
source: '^@/app/components/base/icons/src/(?<set>(?:public|vender)(?:/.*)?)$',
|
||||
name: '^(?<name>.*)$',
|
||||
},
|
||||
{
|
||||
source: '^@remixicon/react$',
|
||||
name: '^(?<set>Ri)(?<name>.+)$',
|
||||
},
|
||||
{
|
||||
source: '^@(?<set>heroicons)/react/24/outline$',
|
||||
name: '^(?<name>.*)Icon$',
|
||||
},
|
||||
{
|
||||
source: '^@(?<set>heroicons)/react/24/(?<variant>solid)$',
|
||||
name: '^(?<name>.*)Icon$',
|
||||
},
|
||||
{
|
||||
source: '^@(?<set>heroicons)/react/(?<variant>\\d+/(?:solid|outline))$',
|
||||
name: '^(?<name>.*)Icon$',
|
||||
},
|
||||
],
|
||||
}],
|
||||
'hyoban/prefer-tailwind-icons': ['warn', HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,96 @@
|
|||
export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [
|
||||
{
|
||||
name: 'next',
|
||||
message: 'Import Next APIs from the corresponding @/next module instead of next.',
|
||||
},
|
||||
]
|
||||
|
||||
export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
|
||||
{
|
||||
group: ['next/image'],
|
||||
message: 'Do not import next/image. Use native img tags instead.',
|
||||
},
|
||||
{
|
||||
group: ['next/font', 'next/font/*'],
|
||||
message: 'Do not import next/font. Use the project font styles instead.',
|
||||
},
|
||||
{
|
||||
group: ['next/*', '!next/font', '!next/font/*', '!next/image', '!next/image/*'],
|
||||
message: 'Import Next APIs from the corresponding @/next/* module instead of next/*.',
|
||||
},
|
||||
]
|
||||
|
||||
export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
||||
{
|
||||
group: [
|
||||
'**/portal-to-follow-elem',
|
||||
'**/portal-to-follow-elem/index',
|
||||
],
|
||||
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/tooltip',
|
||||
'**/base/tooltip/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/modal',
|
||||
'**/base/modal/index',
|
||||
'**/base/modal/modal',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/select',
|
||||
'**/base/select/index',
|
||||
'**/base/select/custom',
|
||||
'**/base/select/pure',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/confirm',
|
||||
'**/base/confirm/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/popover',
|
||||
'**/base/popover/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/dropdown',
|
||||
'**/base/dropdown/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/dialog',
|
||||
'**/base/dialog/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/toast',
|
||||
'**/base/toast/index',
|
||||
'**/base/toast/context',
|
||||
'**/base/toast/context/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.',
|
||||
},
|
||||
]
|
||||
|
||||
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
|
||||
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
|
||||
'app/components/base/chat/chat-with-history/header/operation.tsx',
|
||||
|
|
@ -27,3 +120,35 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
|
|||
'app/components/base/theme-selector.tsx',
|
||||
'app/components/base/tooltip/index.tsx',
|
||||
]
|
||||
|
||||
export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = {
|
||||
prefix: 'i-',
|
||||
propMappings: {
|
||||
size: 'size',
|
||||
width: 'w',
|
||||
height: 'h',
|
||||
},
|
||||
libraries: [
|
||||
{
|
||||
prefix: 'i-custom-',
|
||||
source: '^@/app/components/base/icons/src/(?<set>(?:public|vender)(?:/.*)?)$',
|
||||
name: '^(?<name>.*)$',
|
||||
},
|
||||
{
|
||||
source: '^@remixicon/react$',
|
||||
name: '^(?<set>Ri)(?<name>.+)$',
|
||||
},
|
||||
{
|
||||
source: '^@(?<set>heroicons)/react/24/outline$',
|
||||
name: '^(?<name>.*)Icon$',
|
||||
},
|
||||
{
|
||||
source: '^@(?<set>heroicons)/react/24/(?<variant>solid)$',
|
||||
name: '^(?<name>.*)Icon$',
|
||||
},
|
||||
{
|
||||
source: '^@(?<set>heroicons)/react/(?<variant>\\d+/(?:solid|outline))$',
|
||||
name: '^(?<name>.*)Icon$',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,407 +0,0 @@
|
|||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const DIFF_COVERAGE_IGNORE_LINE_TOKEN = 'diff-coverage-ignore-line:'
|
||||
const DEFAULT_BRANCH_REF_CANDIDATES = ['origin/main', 'main']
|
||||
|
||||
export function normalizeDiffRangeMode(mode) {
|
||||
return mode === 'exact' ? 'exact' : 'merge-base'
|
||||
}
|
||||
|
||||
export function buildGitDiffRevisionArgs(base, head, mode = 'merge-base') {
|
||||
return mode === 'exact'
|
||||
? [base, head]
|
||||
: [`${base}...${head}`]
|
||||
}
|
||||
|
||||
export function resolveGitDiffContext({
|
||||
base,
|
||||
head,
|
||||
mode = 'merge-base',
|
||||
execGit,
|
||||
}) {
|
||||
const requestedMode = normalizeDiffRangeMode(mode)
|
||||
const context = {
|
||||
base,
|
||||
head,
|
||||
mode: requestedMode,
|
||||
requestedMode,
|
||||
reason: null,
|
||||
useCombinedMergeDiff: false,
|
||||
}
|
||||
|
||||
if (requestedMode !== 'exact' || !base || !head || !execGit)
|
||||
return context
|
||||
|
||||
const baseCommit = resolveCommitSha(base, execGit) ?? base
|
||||
const headCommit = resolveCommitSha(head, execGit) ?? head
|
||||
const parents = getCommitParents(headCommit, execGit)
|
||||
if (parents.length < 2)
|
||||
return context
|
||||
|
||||
const [firstParent, secondParent] = parents
|
||||
if (firstParent !== baseCommit)
|
||||
return context
|
||||
|
||||
const defaultBranchRef = resolveDefaultBranchRef(execGit)
|
||||
if (!defaultBranchRef || !isAncestor(secondParent, defaultBranchRef, execGit))
|
||||
return context
|
||||
|
||||
return {
|
||||
...context,
|
||||
reason: `ignored merge from ${defaultBranchRef}`,
|
||||
useCombinedMergeDiff: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseChangedLineMap(diff, isTrackedComponentSourceFile) {
|
||||
const lineMap = new Map()
|
||||
let currentFile = null
|
||||
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+++ b/')) {
|
||||
currentFile = line.slice(6).trim()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentFile || !isTrackedComponentSourceFile(currentFile))
|
||||
continue
|
||||
|
||||
const match = line.match(/^@{2,}(?: -\d+(?:,\d+)?)+ \+(\d+)(?:,(\d+))? @{2,}/)
|
||||
if (!match)
|
||||
continue
|
||||
|
||||
const start = Number(match[1])
|
||||
const count = match[2] ? Number(match[2]) : 1
|
||||
if (count === 0)
|
||||
continue
|
||||
|
||||
const linesForFile = lineMap.get(currentFile) ?? new Set()
|
||||
for (let offset = 0; offset < count; offset += 1)
|
||||
linesForFile.add(start + offset)
|
||||
lineMap.set(currentFile, linesForFile)
|
||||
}
|
||||
|
||||
return lineMap
|
||||
}
|
||||
|
||||
export function normalizeToRepoRelative(filePath, {
|
||||
appComponentsCoveragePrefix,
|
||||
appComponentsPrefix,
|
||||
repoRoot,
|
||||
sharedTestPrefix,
|
||||
webRoot,
|
||||
}) {
|
||||
if (!filePath)
|
||||
return ''
|
||||
|
||||
if (filePath.startsWith(appComponentsPrefix) || filePath.startsWith(sharedTestPrefix))
|
||||
return filePath
|
||||
|
||||
if (filePath.startsWith(appComponentsCoveragePrefix))
|
||||
return `web/${filePath}`
|
||||
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(webRoot, filePath)
|
||||
|
||||
return path.relative(repoRoot, absolutePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
export function getLineHits(entry) {
|
||||
if (entry?.l && Object.keys(entry.l).length > 0)
|
||||
return entry.l
|
||||
|
||||
const lineHits = {}
|
||||
for (const [statementId, statement] of Object.entries(entry?.statementMap ?? {})) {
|
||||
const line = statement?.start?.line
|
||||
if (!line)
|
||||
continue
|
||||
|
||||
const hits = entry?.s?.[statementId] ?? 0
|
||||
const previous = lineHits[line]
|
||||
lineHits[line] = previous === undefined ? hits : Math.max(previous, hits)
|
||||
}
|
||||
|
||||
return lineHits
|
||||
}
|
||||
|
||||
export function getChangedStatementCoverage(entry, changedLines) {
|
||||
const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b)
|
||||
if (!entry) {
|
||||
return {
|
||||
covered: 0,
|
||||
total: normalizedChangedLines.length,
|
||||
uncoveredLines: normalizedChangedLines,
|
||||
}
|
||||
}
|
||||
|
||||
const uncoveredLines = []
|
||||
let covered = 0
|
||||
let total = 0
|
||||
|
||||
for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) {
|
||||
if (!rangeIntersectsChangedLines(statement, changedLines))
|
||||
continue
|
||||
|
||||
total += 1
|
||||
const hits = entry.s?.[statementId] ?? 0
|
||||
if (hits > 0) {
|
||||
covered += 1
|
||||
continue
|
||||
}
|
||||
|
||||
uncoveredLines.push(getFirstChangedLineInRange(statement, normalizedChangedLines))
|
||||
}
|
||||
|
||||
return {
|
||||
covered,
|
||||
total,
|
||||
uncoveredLines: uncoveredLines.sort((a, b) => a - b),
|
||||
}
|
||||
}
|
||||
|
||||
export function getChangedBranchCoverage(entry, changedLines) {
|
||||
const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b)
|
||||
if (!entry) {
|
||||
return {
|
||||
covered: 0,
|
||||
total: 0,
|
||||
uncoveredBranches: [],
|
||||
}
|
||||
}
|
||||
|
||||
const uncoveredBranches = []
|
||||
let covered = 0
|
||||
let total = 0
|
||||
|
||||
for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) {
|
||||
const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : []
|
||||
const locations = getBranchLocations(branch)
|
||||
const armCount = Math.max(locations.length, hits.length)
|
||||
const impactedArmIndexes = getImpactedBranchArmIndexes(branch, changedLines, armCount)
|
||||
|
||||
if (impactedArmIndexes.length === 0)
|
||||
continue
|
||||
|
||||
for (const armIndex of impactedArmIndexes) {
|
||||
total += 1
|
||||
if ((hits[armIndex] ?? 0) > 0) {
|
||||
covered += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const location = locations[armIndex] ?? branch.loc ?? branch
|
||||
uncoveredBranches.push({
|
||||
armIndex,
|
||||
line: getFirstChangedLineInRange(location, normalizedChangedLines, branch.line ?? 1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
uncoveredBranches.sort((a, b) => a.line - b.line || a.armIndex - b.armIndex)
|
||||
return {
|
||||
covered,
|
||||
total,
|
||||
uncoveredBranches,
|
||||
}
|
||||
}
|
||||
|
||||
export function getIgnoredChangedLinesFromFile(filePath, changedLines) {
|
||||
if (!fs.existsSync(filePath))
|
||||
return emptyIgnoreResult(changedLines)
|
||||
|
||||
const sourceCode = fs.readFileSync(filePath, 'utf8')
|
||||
return getIgnoredChangedLinesFromSource(sourceCode, changedLines)
|
||||
}
|
||||
|
||||
export function getIgnoredChangedLinesFromSource(sourceCode, changedLines) {
|
||||
const ignoredLines = new Map()
|
||||
const invalidPragmas = []
|
||||
const changedLineSet = new Set(changedLines ?? [])
|
||||
|
||||
const sourceLines = sourceCode.split('\n')
|
||||
sourceLines.forEach((lineText, index) => {
|
||||
const lineNumber = index + 1
|
||||
const commentIndex = lineText.indexOf('//')
|
||||
if (commentIndex < 0)
|
||||
return
|
||||
|
||||
const tokenIndex = lineText.indexOf(DIFF_COVERAGE_IGNORE_LINE_TOKEN, commentIndex + 2)
|
||||
if (tokenIndex < 0)
|
||||
return
|
||||
|
||||
const reason = lineText.slice(tokenIndex + DIFF_COVERAGE_IGNORE_LINE_TOKEN.length).trim()
|
||||
if (!changedLineSet.has(lineNumber))
|
||||
return
|
||||
|
||||
if (!reason) {
|
||||
invalidPragmas.push({
|
||||
line: lineNumber,
|
||||
reason: 'missing ignore reason',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ignoredLines.set(lineNumber, reason)
|
||||
})
|
||||
|
||||
const effectiveChangedLines = new Set(
|
||||
[...changedLineSet].filter(lineNumber => !ignoredLines.has(lineNumber)),
|
||||
)
|
||||
|
||||
return {
|
||||
effectiveChangedLines,
|
||||
ignoredLines,
|
||||
invalidPragmas,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyIgnoreResult(changedLines = []) {
|
||||
return {
|
||||
effectiveChangedLines: new Set(changedLines),
|
||||
ignoredLines: new Map(),
|
||||
invalidPragmas: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getCommitParents(ref, execGit) {
|
||||
const output = tryExecGit(execGit, ['rev-list', '--parents', '-n', '1', ref])
|
||||
if (!output)
|
||||
return []
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(1)
|
||||
}
|
||||
|
||||
function resolveCommitSha(ref, execGit) {
|
||||
return tryExecGit(execGit, ['rev-parse', '--verify', ref])?.trim() ?? null
|
||||
}
|
||||
|
||||
function resolveDefaultBranchRef(execGit) {
|
||||
const originHeadRef = tryExecGit(execGit, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])?.trim()
|
||||
if (originHeadRef)
|
||||
return originHeadRef
|
||||
|
||||
for (const ref of DEFAULT_BRANCH_REF_CANDIDATES) {
|
||||
if (tryExecGit(execGit, ['rev-parse', '--verify', '-q', ref]))
|
||||
return ref
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isAncestor(ancestorRef, descendantRef, execGit) {
|
||||
try {
|
||||
execGit(['merge-base', '--is-ancestor', ancestorRef, descendantRef])
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function tryExecGit(execGit, args) {
|
||||
try {
|
||||
return execGit(args)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getBranchLocations(branch) {
|
||||
return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
|
||||
}
|
||||
|
||||
function getImpactedBranchArmIndexes(branch, changedLines, armCount) {
|
||||
if (!changedLines || changedLines.size === 0 || armCount === 0)
|
||||
return []
|
||||
|
||||
const locations = getBranchLocations(branch)
|
||||
if (isWholeBranchTouched(branch, changedLines, locations, armCount))
|
||||
return Array.from({ length: armCount }, (_, armIndex) => armIndex)
|
||||
|
||||
const impactedArmIndexes = []
|
||||
for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
|
||||
const location = locations[armIndex]
|
||||
if (rangeIntersectsChangedLines(location, changedLines))
|
||||
impactedArmIndexes.push(armIndex)
|
||||
}
|
||||
|
||||
return impactedArmIndexes
|
||||
}
|
||||
|
||||
function isWholeBranchTouched(branch, changedLines, locations, armCount) {
|
||||
if (!changedLines || changedLines.size === 0)
|
||||
return false
|
||||
|
||||
if (branch.line && changedLines.has(branch.line))
|
||||
return true
|
||||
|
||||
const branchRange = branch.loc ?? branch
|
||||
if (!rangeIntersectsChangedLines(branchRange, changedLines))
|
||||
return false
|
||||
|
||||
if (locations.length === 0 || locations.length < armCount)
|
||||
return true
|
||||
|
||||
for (const lineNumber of changedLines) {
|
||||
if (!lineTouchesLocation(lineNumber, branchRange))
|
||||
continue
|
||||
if (!locations.some(location => lineTouchesLocation(lineNumber, location)))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function rangeIntersectsChangedLines(location, changedLines) {
|
||||
if (!location || !changedLines || changedLines.size === 0)
|
||||
return false
|
||||
|
||||
const startLine = getLocationStartLine(location)
|
||||
const endLine = getLocationEndLine(location) ?? startLine
|
||||
if (!startLine || !endLine)
|
||||
return false
|
||||
|
||||
for (const lineNumber of changedLines) {
|
||||
if (lineNumber >= startLine && lineNumber <= endLine)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) {
|
||||
const startLine = getLocationStartLine(location)
|
||||
const endLine = getLocationEndLine(location) ?? startLine
|
||||
if (!startLine || !endLine)
|
||||
return startLine ?? fallbackLine
|
||||
|
||||
for (const lineNumber of changedLines) {
|
||||
if (lineNumber >= startLine && lineNumber <= endLine)
|
||||
return lineNumber
|
||||
}
|
||||
|
||||
return startLine ?? fallbackLine
|
||||
}
|
||||
|
||||
function lineTouchesLocation(lineNumber, location) {
|
||||
const startLine = getLocationStartLine(location)
|
||||
const endLine = getLocationEndLine(location) ?? startLine
|
||||
if (!startLine || !endLine)
|
||||
return false
|
||||
|
||||
return lineNumber >= startLine && lineNumber <= endLine
|
||||
}
|
||||
|
||||
function getLocationStartLine(location) {
|
||||
return location?.start?.line ?? location?.line ?? null
|
||||
}
|
||||
|
||||
function getLocationEndLine(location) {
|
||||
return location?.end?.line ?? location?.line ?? null
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseChangedLineMap, resolveGitDiffContext } from './check-components-diff-coverage-lib.mjs'
|
||||
|
||||
function createExecGitMock(responses: Record<string, string | Error>) {
|
||||
return vi.fn((args: string[]) => {
|
||||
const key = args.join(' ')
|
||||
const response = responses[key]
|
||||
|
||||
if (response instanceof Error)
|
||||
throw response
|
||||
|
||||
if (response === undefined)
|
||||
throw new Error(`Unexpected git args: ${key}`)
|
||||
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveGitDiffContext', () => {
|
||||
it('switches exact diff to combined merge diff when head merges origin/main into the branch', () => {
|
||||
const execGit = createExecGitMock({
|
||||
'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n',
|
||||
'rev-parse --verify merge-sha': 'merge-sha\n',
|
||||
'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha main-parent-sha\n',
|
||||
'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main\n',
|
||||
'merge-base --is-ancestor main-parent-sha origin/main': '',
|
||||
})
|
||||
|
||||
expect(resolveGitDiffContext({
|
||||
base: 'feature-parent-sha',
|
||||
head: 'merge-sha',
|
||||
mode: 'exact',
|
||||
execGit,
|
||||
})).toEqual({
|
||||
base: 'feature-parent-sha',
|
||||
head: 'merge-sha',
|
||||
mode: 'exact',
|
||||
requestedMode: 'exact',
|
||||
reason: 'ignored merge from origin/main',
|
||||
useCombinedMergeDiff: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to origin/main when origin/HEAD is unavailable', () => {
|
||||
const execGit = createExecGitMock({
|
||||
'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n',
|
||||
'rev-parse --verify merge-sha': 'merge-sha\n',
|
||||
'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha main-parent-sha\n',
|
||||
'symbolic-ref --quiet --short refs/remotes/origin/HEAD': new Error('missing origin/HEAD'),
|
||||
'rev-parse --verify -q origin/main': 'main-tip-sha\n',
|
||||
'merge-base --is-ancestor main-parent-sha origin/main': '',
|
||||
})
|
||||
|
||||
expect(resolveGitDiffContext({
|
||||
base: 'feature-parent-sha',
|
||||
head: 'merge-sha',
|
||||
mode: 'exact',
|
||||
execGit,
|
||||
})).toEqual({
|
||||
base: 'feature-parent-sha',
|
||||
head: 'merge-sha',
|
||||
mode: 'exact',
|
||||
requestedMode: 'exact',
|
||||
reason: 'ignored merge from origin/main',
|
||||
useCombinedMergeDiff: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps exact diff when the second parent is not the default branch', () => {
|
||||
const execGit = createExecGitMock({
|
||||
'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n',
|
||||
'rev-parse --verify merge-sha': 'merge-sha\n',
|
||||
'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha topic-parent-sha\n',
|
||||
'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main\n',
|
||||
'merge-base --is-ancestor topic-parent-sha origin/main': new Error('not ancestor'),
|
||||
})
|
||||
|
||||
expect(resolveGitDiffContext({
|
||||
base: 'feature-parent-sha',
|
||||
head: 'merge-sha',
|
||||
mode: 'exact',
|
||||
execGit,
|
||||
})).toEqual({
|
||||
base: 'feature-parent-sha',
|
||||
head: 'merge-sha',
|
||||
mode: 'exact',
|
||||
requestedMode: 'exact',
|
||||
reason: null,
|
||||
useCombinedMergeDiff: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseChangedLineMap', () => {
|
||||
it('parses regular diff hunks', () => {
|
||||
const diff = [
|
||||
'diff --git a/web/app/components/example.tsx b/web/app/components/example.tsx',
|
||||
'+++ b/web/app/components/example.tsx',
|
||||
'@@ -10,0 +11,2 @@',
|
||||
].join('\n')
|
||||
|
||||
const changedLineMap = parseChangedLineMap(diff, () => true)
|
||||
|
||||
expect([...changedLineMap.get('web/app/components/example.tsx') ?? []]).toEqual([11, 12])
|
||||
})
|
||||
|
||||
it('parses combined merge diff hunks', () => {
|
||||
const diff = [
|
||||
'diff --cc web/app/components/example.tsx',
|
||||
'+++ b/web/app/components/example.tsx',
|
||||
'@@@ -10,0 -10,0 +11,3 @@@',
|
||||
].join('\n')
|
||||
|
||||
const changedLineMap = parseChangedLineMap(diff, () => true)
|
||||
|
||||
expect([...changedLineMap.get('web/app/components/example.tsx') ?? []]).toEqual([11, 12, 13])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
buildGitDiffRevisionArgs,
|
||||
getChangedBranchCoverage,
|
||||
getChangedStatementCoverage,
|
||||
getIgnoredChangedLinesFromFile,
|
||||
normalizeDiffRangeMode,
|
||||
parseChangedLineMap,
|
||||
resolveGitDiffContext,
|
||||
} from './check-components-diff-coverage-lib.mjs'
|
||||
import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs'
|
||||
import {
|
||||
APP_COMPONENTS_PREFIX,
|
||||
createComponentCoverageContext,
|
||||
getModuleName,
|
||||
isAnyComponentSourceFile,
|
||||
isExcludedComponentSourceFile,
|
||||
isTrackedComponentSourceFile,
|
||||
loadTrackedCoverageEntries,
|
||||
} from './components-coverage-common.mjs'
|
||||
import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs'
|
||||
|
||||
const REQUESTED_DIFF_RANGE_MODE = normalizeDiffRangeMode(process.env.DIFF_RANGE_MODE)
|
||||
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
|
||||
|
||||
const repoRoot = repoRootFromCwd()
|
||||
const context = createComponentCoverageContext(repoRoot)
|
||||
const baseSha = process.env.BASE_SHA?.trim()
|
||||
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
|
||||
const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json')
|
||||
|
||||
if (!baseSha || /^0+$/.test(baseSha)) {
|
||||
appendSummary([
|
||||
'### app/components Pure Diff Coverage',
|
||||
'',
|
||||
'Skipped pure diff coverage check because `BASE_SHA` was not available.',
|
||||
])
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(coverageFinalPath)) {
|
||||
console.error(`Coverage report not found at ${coverageFinalPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const diffContext = resolveGitDiffContext({
|
||||
base: baseSha,
|
||||
head: headSha,
|
||||
mode: REQUESTED_DIFF_RANGE_MODE,
|
||||
execGit,
|
||||
})
|
||||
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
|
||||
const changedFiles = getChangedFiles(diffContext)
|
||||
const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile)
|
||||
const changedSourceFiles = changedComponentSourceFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
|
||||
const changedExcludedSourceFiles = changedComponentSourceFiles.filter(filePath => isExcludedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
|
||||
|
||||
if (changedSourceFiles.length === 0) {
|
||||
appendSummary(buildSkipSummary(changedExcludedSourceFiles))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const coverageEntries = loadTrackedCoverageEntries(coverage, context)
|
||||
const diffChanges = getChangedLineMap(diffContext)
|
||||
const diffRows = []
|
||||
const ignoredDiffLines = []
|
||||
const invalidIgnorePragmas = []
|
||||
|
||||
for (const [file, changedLines] of diffChanges.entries()) {
|
||||
if (!isTrackedComponentSourceFile(file, context.excludedComponentCoverageFiles))
|
||||
continue
|
||||
|
||||
const entry = coverageEntries.get(file)
|
||||
const ignoreInfo = getIgnoredChangedLinesFromFile(path.join(repoRoot, file), changedLines)
|
||||
|
||||
for (const [line, reason] of ignoreInfo.ignoredLines.entries()) {
|
||||
ignoredDiffLines.push({
|
||||
file,
|
||||
line,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
for (const invalidPragma of ignoreInfo.invalidPragmas) {
|
||||
invalidIgnorePragmas.push({
|
||||
file,
|
||||
...invalidPragma,
|
||||
})
|
||||
}
|
||||
|
||||
const statements = getChangedStatementCoverage(entry, ignoreInfo.effectiveChangedLines)
|
||||
const branches = getChangedBranchCoverage(entry, ignoreInfo.effectiveChangedLines)
|
||||
diffRows.push({
|
||||
branches,
|
||||
file,
|
||||
ignoredLineCount: ignoreInfo.ignoredLines.size,
|
||||
moduleName: getModuleName(file),
|
||||
statements,
|
||||
})
|
||||
}
|
||||
|
||||
const diffTotals = diffRows.reduce((acc, row) => {
|
||||
acc.statements.total += row.statements.total
|
||||
acc.statements.covered += row.statements.covered
|
||||
acc.branches.total += row.branches.total
|
||||
acc.branches.covered += row.branches.covered
|
||||
return acc
|
||||
}, {
|
||||
branches: { total: 0, covered: 0 },
|
||||
statements: { total: 0, covered: 0 },
|
||||
})
|
||||
|
||||
const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0)
|
||||
const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0)
|
||||
|
||||
appendSummary(buildSummary({
|
||||
changedSourceFiles,
|
||||
diffContext,
|
||||
diffBranchFailures,
|
||||
diffRows,
|
||||
diffStatementFailures,
|
||||
diffTotals,
|
||||
ignoredDiffLines,
|
||||
invalidIgnorePragmas,
|
||||
}))
|
||||
|
||||
if (process.env.CI) {
|
||||
for (const failure of diffStatementFailures.slice(0, 20)) {
|
||||
const firstLine = failure.statements.uncoveredLines[0] ?? 1
|
||||
console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed statements: ${formatLineRanges(failure.statements.uncoveredLines)}`)
|
||||
}
|
||||
|
||||
for (const failure of diffBranchFailures.slice(0, 20)) {
|
||||
const firstBranch = failure.branches.uncoveredBranches[0]
|
||||
const line = firstBranch?.line ?? 1
|
||||
console.log(`::error file=${failure.file},line=${line}::Uncovered changed branches: ${formatBranchRefs(failure.branches.uncoveredBranches)}`)
|
||||
}
|
||||
|
||||
for (const invalidPragma of invalidIgnorePragmas.slice(0, 20)) {
|
||||
console.log(`::error file=${invalidPragma.file},line=${invalidPragma.line}::Invalid diff coverage ignore pragma: ${invalidPragma.reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
diffStatementFailures.length > 0
|
||||
|| diffBranchFailures.length > 0
|
||||
|| invalidIgnorePragmas.length > 0
|
||||
) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function buildSummary({
|
||||
changedSourceFiles,
|
||||
diffContext,
|
||||
diffBranchFailures,
|
||||
diffRows,
|
||||
diffStatementFailures,
|
||||
diffTotals,
|
||||
ignoredDiffLines,
|
||||
invalidIgnorePragmas,
|
||||
}) {
|
||||
const lines = [
|
||||
'### app/components Pure Diff Coverage',
|
||||
'',
|
||||
...buildDiffContextSummary(diffContext),
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
'| Check | Result | Details |',
|
||||
'|---|---:|---|',
|
||||
`| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`,
|
||||
`| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`,
|
||||
'',
|
||||
]
|
||||
|
||||
const changedRows = diffRows
|
||||
.filter(row => row.statements.total > 0 || row.branches.total > 0)
|
||||
.sort((a, b) => {
|
||||
const aScore = percentage(a.statements.covered + a.branches.covered, a.statements.total + a.branches.total)
|
||||
const bScore = percentage(b.statements.covered + b.branches.covered, b.statements.total + b.branches.total)
|
||||
return aScore - bScore || a.file.localeCompare(b.file)
|
||||
})
|
||||
|
||||
lines.push('<details><summary>Changed file coverage</summary>')
|
||||
lines.push('')
|
||||
lines.push('| File | Module | Changed statements | Statement coverage | Uncovered statements | Changed branches | Branch coverage | Uncovered branches | Ignored lines |')
|
||||
lines.push('|---|---|---:|---:|---|---:|---:|---|---:|')
|
||||
for (const row of changedRows) {
|
||||
lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.statements.total} | ${formatDiffPercent(row.statements)} | ${formatLineRanges(row.statements.uncoveredLines)} | ${row.branches.total} | ${formatDiffPercent(row.branches)} | ${formatBranchRefs(row.branches.uncoveredBranches)} | ${row.ignoredLineCount} |`)
|
||||
}
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
|
||||
if (diffStatementFailures.length > 0) {
|
||||
lines.push('Uncovered changed statements:')
|
||||
for (const row of diffStatementFailures)
|
||||
lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.statements.uncoveredLines)}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (diffBranchFailures.length > 0) {
|
||||
lines.push('Uncovered changed branches:')
|
||||
for (const row of diffBranchFailures)
|
||||
lines.push(`- ${row.file.replace('web/', '')}: ${formatBranchRefs(row.branches.uncoveredBranches)}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (ignoredDiffLines.length > 0) {
|
||||
lines.push('Ignored changed lines via pragma:')
|
||||
for (const ignoredLine of ignoredDiffLines)
|
||||
lines.push(`- ${ignoredLine.file.replace('web/', '')}:${ignoredLine.line} - ${ignoredLine.reason}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (invalidIgnorePragmas.length > 0) {
|
||||
lines.push('Invalid diff coverage ignore pragmas:')
|
||||
for (const invalidPragma of invalidIgnorePragmas)
|
||||
lines.push(`- ${invalidPragma.file.replace('web/', '')}:${invalidPragma.line} - ${invalidPragma.reason}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`Changed source files checked: ${changedSourceFiles.length}`)
|
||||
lines.push('Blocking rules: uncovered changed statements, uncovered changed branches, invalid ignore pragmas.')
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildSkipSummary(changedExcludedSourceFiles) {
|
||||
const lines = [
|
||||
'### app/components Pure Diff Coverage',
|
||||
'',
|
||||
...buildDiffContextSummary(diffContext),
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
]
|
||||
|
||||
if (changedExcludedSourceFiles.length > 0) {
|
||||
lines.push('Only excluded component modules or type-only files changed, so pure diff coverage was skipped.')
|
||||
lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`)
|
||||
}
|
||||
else {
|
||||
lines.push('No tracked source changes under `web/app/components/`. Pure diff coverage skipped.')
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildDiffContextSummary(diffContext) {
|
||||
const lines = [
|
||||
`Compared \`${diffContext.base.slice(0, 12)}\` -> \`${diffContext.head.slice(0, 12)}\``,
|
||||
]
|
||||
|
||||
if (diffContext.useCombinedMergeDiff) {
|
||||
lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``)
|
||||
lines.push(`Effective diff strategy: \`combined-merge\` (${diffContext.reason})`)
|
||||
}
|
||||
else if (diffContext.reason) {
|
||||
lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``)
|
||||
lines.push(`Effective diff range mode: \`${diffContext.mode}\` (${diffContext.reason})`)
|
||||
}
|
||||
else {
|
||||
lines.push(`Diff range mode: \`${diffContext.mode}\``)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function getChangedFiles(diffContext) {
|
||||
if (diffContext.useCombinedMergeDiff) {
|
||||
const output = execGit(['diff-tree', '--cc', '--no-commit-id', '--name-only', '-r', diffContext.head, '--', APP_COMPONENTS_PREFIX])
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', APP_COMPONENTS_PREFIX])
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getChangedLineMap(diffContext) {
|
||||
if (diffContext.useCombinedMergeDiff) {
|
||||
const diff = execGit(['diff-tree', '--cc', '--no-commit-id', '-r', '--unified=0', diffContext.head, '--', APP_COMPONENTS_PREFIX])
|
||||
return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
|
||||
}
|
||||
|
||||
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', APP_COMPONENTS_PREFIX])
|
||||
return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
|
||||
}
|
||||
|
||||
function formatLineRanges(lines) {
|
||||
if (!lines || lines.length === 0)
|
||||
return ''
|
||||
|
||||
const ranges = []
|
||||
let start = lines[0]
|
||||
let end = lines[0]
|
||||
|
||||
for (let index = 1; index < lines.length; index += 1) {
|
||||
const current = lines[index]
|
||||
if (current === end + 1) {
|
||||
end = current
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`)
|
||||
start = current
|
||||
end = current
|
||||
}
|
||||
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`)
|
||||
return ranges.join(', ')
|
||||
}
|
||||
|
||||
function formatBranchRefs(branches) {
|
||||
if (!branches || branches.length === 0)
|
||||
return ''
|
||||
|
||||
return branches.map(branch => `${branch.line}[${branch.armIndex}]`).join(', ')
|
||||
}
|
||||
|
||||
function percentage(covered, total) {
|
||||
if (total === 0)
|
||||
return 100
|
||||
return (covered / total) * 100
|
||||
}
|
||||
|
||||
function formatDiffPercent(metric) {
|
||||
if (metric.total === 0)
|
||||
return 'n/a'
|
||||
|
||||
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function appendSummary(lines) {
|
||||
const content = `${lines.join('\n')}\n`
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
|
||||
console.log(content)
|
||||
}
|
||||
|
||||
function execGit(args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
function repoRootFromCwd() {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
|
||||
const TS_TSX_FILE_PATTERN = /\.(?:ts|tsx)$/
|
||||
const TYPE_COVERAGE_EXCLUDE_BASENAMES = new Set([
|
||||
'type',
|
||||
'types',
|
||||
'declarations',
|
||||
])
|
||||
const GENERATED_FILE_COMMENT_PATTERNS = [
|
||||
/@generated/i,
|
||||
/\bauto-?generated\b/i,
|
||||
/\bgenerated by\b/i,
|
||||
/\bgenerate by\b/i,
|
||||
/\bdo not edit\b/i,
|
||||
/\bdon not edit\b/i,
|
||||
]
|
||||
const PARSER_OPTIONS = {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
}
|
||||
|
||||
const collectedExcludedFilesCache = new Map()
|
||||
|
||||
export const COMPONENT_COVERAGE_EXCLUDE_LABEL = 'type-only files, pure barrel files, generated files, pure static files'
|
||||
|
||||
export function isTypeCoverageExcludedComponentFile(filePath) {
|
||||
return TYPE_COVERAGE_EXCLUDE_BASENAMES.has(getPathBaseNameWithoutExtension(filePath))
|
||||
}
|
||||
|
||||
export function getComponentCoverageExclusionReasons(filePath, sourceCode) {
|
||||
if (!isEligibleComponentSourceFilePath(filePath))
|
||||
return []
|
||||
|
||||
const reasons = []
|
||||
if (isTypeCoverageExcludedComponentFile(filePath))
|
||||
reasons.push('type-only')
|
||||
|
||||
if (typeof sourceCode !== 'string' || sourceCode.length === 0)
|
||||
return reasons
|
||||
|
||||
if (isGeneratedComponentFile(sourceCode))
|
||||
reasons.push('generated')
|
||||
|
||||
const ast = parseComponentFile(sourceCode)
|
||||
if (!ast)
|
||||
return reasons
|
||||
|
||||
if (isPureBarrelComponentFile(ast))
|
||||
reasons.push('pure-barrel')
|
||||
else if (isPureStaticComponentFile(ast))
|
||||
reasons.push('pure-static')
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
export function collectComponentCoverageExcludedFiles(rootDir, options = {}) {
|
||||
const normalizedRootDir = path.resolve(rootDir)
|
||||
const pathPrefix = normalizePathPrefix(options.pathPrefix ?? '')
|
||||
const cacheKey = `${normalizedRootDir}::${pathPrefix}`
|
||||
const cached = collectedExcludedFilesCache.get(cacheKey)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const files = []
|
||||
walkComponentFiles(normalizedRootDir, (absolutePath) => {
|
||||
const relativePath = path.relative(normalizedRootDir, absolutePath).split(path.sep).join('/')
|
||||
const prefixedPath = pathPrefix ? `${pathPrefix}/${relativePath}` : relativePath
|
||||
const sourceCode = fs.readFileSync(absolutePath, 'utf8')
|
||||
if (getComponentCoverageExclusionReasons(prefixedPath, sourceCode).length > 0)
|
||||
files.push(prefixedPath)
|
||||
})
|
||||
|
||||
files.sort((a, b) => a.localeCompare(b))
|
||||
collectedExcludedFilesCache.set(cacheKey, files)
|
||||
return files
|
||||
}
|
||||
|
||||
function normalizePathPrefix(pathPrefix) {
|
||||
return pathPrefix.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function walkComponentFiles(currentDir, onFile) {
|
||||
if (!fs.existsSync(currentDir))
|
||||
return
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__' || entry.name === '__mocks__')
|
||||
continue
|
||||
walkComponentFiles(entryPath, onFile)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isEligibleComponentSourceFilePath(entry.name))
|
||||
continue
|
||||
|
||||
onFile(entryPath)
|
||||
}
|
||||
}
|
||||
|
||||
function isEligibleComponentSourceFilePath(filePath) {
|
||||
return TS_TSX_FILE_PATTERN.test(filePath)
|
||||
&& !isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
function isTestLikePath(filePath) {
|
||||
return /(?:^|\/)__tests__\//.test(filePath)
|
||||
|| /(?:^|\/)__mocks__\//.test(filePath)
|
||||
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.d\.ts$/.test(filePath)
|
||||
}
|
||||
|
||||
function getPathBaseNameWithoutExtension(filePath) {
|
||||
if (!filePath)
|
||||
return ''
|
||||
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
const fileName = normalizedPath.split('/').pop() ?? ''
|
||||
return fileName.replace(TS_TSX_FILE_PATTERN, '')
|
||||
}
|
||||
|
||||
function isGeneratedComponentFile(sourceCode) {
|
||||
const leadingText = sourceCode.split('\n').slice(0, 5).join('\n')
|
||||
return GENERATED_FILE_COMMENT_PATTERNS.some(pattern => pattern.test(leadingText))
|
||||
}
|
||||
|
||||
function parseComponentFile(sourceCode) {
|
||||
try {
|
||||
return tsParser.parse(sourceCode, PARSER_OPTIONS)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isPureBarrelComponentFile(ast) {
|
||||
let hasRuntimeReExports = false
|
||||
|
||||
for (const statement of ast.body) {
|
||||
if (statement.type === 'ExportAllDeclaration') {
|
||||
hasRuntimeReExports = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.source) {
|
||||
hasRuntimeReExports = hasRuntimeReExports || statement.exportKind !== 'type'
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration')
|
||||
continue
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return hasRuntimeReExports
|
||||
}
|
||||
|
||||
function isPureStaticComponentFile(ast) {
|
||||
const importedStaticBindings = collectImportedStaticBindings(ast.body)
|
||||
const staticBindings = new Set()
|
||||
let hasRuntimeValue = false
|
||||
|
||||
for (const statement of ast.body) {
|
||||
if (statement.type === 'ImportDeclaration')
|
||||
continue
|
||||
|
||||
if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration')
|
||||
continue
|
||||
|
||||
if (statement.type === 'ExportAllDeclaration')
|
||||
return false
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.source)
|
||||
return false
|
||||
|
||||
if (statement.type === 'ExportDefaultDeclaration') {
|
||||
if (!isStaticExpression(statement.declaration, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.declaration) {
|
||||
if (!handleStaticDeclaration(statement.declaration, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.specifiers.length > 0) {
|
||||
const allStaticSpecifiers = statement.specifiers.every((specifier) => {
|
||||
if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type')
|
||||
return false
|
||||
return specifier.local.type === 'Identifier' && staticBindings.has(specifier.local.name)
|
||||
})
|
||||
if (!allStaticSpecifiers)
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!handleStaticDeclaration(statement, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
}
|
||||
|
||||
return hasRuntimeValue
|
||||
}
|
||||
|
||||
function handleStaticDeclaration(statement, staticBindings, importedStaticBindings) {
|
||||
if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const')
|
||||
return false
|
||||
|
||||
for (const declarator of statement.declarations) {
|
||||
if (declarator.id.type !== 'Identifier' || !declarator.init)
|
||||
return false
|
||||
|
||||
if (!isStaticExpression(declarator.init, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
staticBindings.add(declarator.id.name)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectImportedStaticBindings(statements) {
|
||||
const importedBindings = new Set()
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== 'ImportDeclaration')
|
||||
continue
|
||||
|
||||
const importSource = String(statement.source.value ?? '')
|
||||
const isTypeLikeSource = isTypeCoverageExcludedComponentFile(importSource)
|
||||
const importIsStatic = statement.importKind === 'type' || isTypeLikeSource
|
||||
if (!importIsStatic)
|
||||
continue
|
||||
|
||||
for (const specifier of statement.specifiers) {
|
||||
if (specifier.local?.type === 'Identifier')
|
||||
importedBindings.add(specifier.local.name)
|
||||
}
|
||||
}
|
||||
|
||||
return importedBindings
|
||||
}
|
||||
|
||||
function isStaticExpression(node, staticBindings, importedStaticBindings) {
|
||||
switch (node.type) {
|
||||
case 'Literal':
|
||||
return true
|
||||
case 'Identifier':
|
||||
return staticBindings.has(node.name) || importedStaticBindings.has(node.name)
|
||||
case 'TemplateLiteral':
|
||||
return node.expressions.every(expression => isStaticExpression(expression, staticBindings, importedStaticBindings))
|
||||
case 'ArrayExpression':
|
||||
return node.elements.every(element => !element || isStaticExpression(element, staticBindings, importedStaticBindings))
|
||||
case 'ObjectExpression':
|
||||
return node.properties.every((property) => {
|
||||
if (property.type === 'SpreadElement')
|
||||
return isStaticExpression(property.argument, staticBindings, importedStaticBindings)
|
||||
|
||||
if (property.type !== 'Property' || property.method)
|
||||
return false
|
||||
|
||||
if (property.computed && !isStaticExpression(property.key, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
if (property.shorthand)
|
||||
return property.value.type === 'Identifier' && staticBindings.has(property.value.name)
|
||||
|
||||
return isStaticExpression(property.value, staticBindings, importedStaticBindings)
|
||||
})
|
||||
case 'UnaryExpression':
|
||||
return isStaticExpression(node.argument, staticBindings, importedStaticBindings)
|
||||
case 'BinaryExpression':
|
||||
case 'LogicalExpression':
|
||||
return isStaticExpression(node.left, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.right, staticBindings, importedStaticBindings)
|
||||
case 'ConditionalExpression':
|
||||
return isStaticExpression(node.test, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.consequent, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.alternate, staticBindings, importedStaticBindings)
|
||||
case 'MemberExpression':
|
||||
return isStaticMemberExpression(node, staticBindings, importedStaticBindings)
|
||||
case 'ChainExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
case 'TSAsExpression':
|
||||
case 'TSSatisfiesExpression':
|
||||
case 'TSTypeAssertion':
|
||||
case 'TSNonNullExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
case 'ParenthesizedExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isStaticMemberExpression(node, staticBindings, importedStaticBindings) {
|
||||
if (!isStaticExpression(node.object, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
if (!node.computed)
|
||||
return node.property.type === 'Identifier'
|
||||
|
||||
return isStaticExpression(node.property, staticBindings, importedStaticBindings)
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getLineHits, normalizeToRepoRelative } from './check-components-diff-coverage-lib.mjs'
|
||||
import { collectComponentCoverageExcludedFiles } from './component-coverage-filters.mjs'
|
||||
import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs'
|
||||
|
||||
export const APP_COMPONENTS_ROOT = 'web/app/components'
|
||||
export const APP_COMPONENTS_PREFIX = `${APP_COMPONENTS_ROOT}/`
|
||||
export const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/'
|
||||
export const SHARED_TEST_PREFIX = 'web/__tests__/'
|
||||
|
||||
export function createComponentCoverageContext(repoRoot) {
|
||||
const webRoot = path.join(repoRoot, 'web')
|
||||
const excludedComponentCoverageFiles = new Set(
|
||||
collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: APP_COMPONENTS_ROOT }),
|
||||
)
|
||||
|
||||
return {
|
||||
excludedComponentCoverageFiles,
|
||||
repoRoot,
|
||||
webRoot,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTrackedCoverageEntries(coverage, context) {
|
||||
const coverageEntries = new Map()
|
||||
|
||||
for (const [file, entry] of Object.entries(coverage)) {
|
||||
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, {
|
||||
appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX,
|
||||
appComponentsPrefix: APP_COMPONENTS_PREFIX,
|
||||
repoRoot: context.repoRoot,
|
||||
sharedTestPrefix: SHARED_TEST_PREFIX,
|
||||
webRoot: context.webRoot,
|
||||
})
|
||||
|
||||
if (!isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles))
|
||||
continue
|
||||
|
||||
coverageEntries.set(repoRelativePath, entry)
|
||||
}
|
||||
|
||||
return coverageEntries
|
||||
}
|
||||
|
||||
export function collectTrackedComponentSourceFiles(context) {
|
||||
const trackedFiles = []
|
||||
|
||||
walkComponentSourceFiles(path.join(context.webRoot, 'app/components'), (absolutePath) => {
|
||||
const repoRelativePath = path.relative(context.repoRoot, absolutePath).split(path.sep).join('/')
|
||||
if (isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles))
|
||||
trackedFiles.push(repoRelativePath)
|
||||
})
|
||||
|
||||
trackedFiles.sort((a, b) => a.localeCompare(b))
|
||||
return trackedFiles
|
||||
}
|
||||
|
||||
export function isTestLikePath(filePath) {
|
||||
return /(?:^|\/)__tests__\//.test(filePath)
|
||||
|| /(?:^|\/)__mocks__\//.test(filePath)
|
||||
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.d\.ts$/.test(filePath)
|
||||
}
|
||||
|
||||
export function getModuleName(filePath) {
|
||||
const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length)
|
||||
if (!relativePath)
|
||||
return '(root)'
|
||||
|
||||
const segments = relativePath.split('/')
|
||||
return segments.length === 1 ? '(root)' : segments[0]
|
||||
}
|
||||
|
||||
export function isAnyComponentSourceFile(filePath) {
|
||||
return filePath.startsWith(APP_COMPONENTS_PREFIX)
|
||||
&& /\.(?:ts|tsx)$/.test(filePath)
|
||||
&& !isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
export function isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) {
|
||||
return isAnyComponentSourceFile(filePath)
|
||||
&& (
|
||||
EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|
||||
|| excludedComponentCoverageFiles.has(filePath)
|
||||
)
|
||||
}
|
||||
|
||||
export function isTrackedComponentSourceFile(filePath, excludedComponentCoverageFiles) {
|
||||
return isAnyComponentSourceFile(filePath)
|
||||
&& !isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles)
|
||||
}
|
||||
|
||||
export function isTrackedComponentTestFile(filePath) {
|
||||
return filePath.startsWith(APP_COMPONENTS_PREFIX)
|
||||
&& isTestLikePath(filePath)
|
||||
&& !EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|
||||
}
|
||||
|
||||
export function isRelevantTestFile(filePath) {
|
||||
return filePath.startsWith(SHARED_TEST_PREFIX)
|
||||
|| isTrackedComponentTestFile(filePath)
|
||||
}
|
||||
|
||||
export function isAnyWebTestFile(filePath) {
|
||||
return filePath.startsWith('web/')
|
||||
&& isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
export function getCoverageStats(entry) {
|
||||
const lineHits = getLineHits(entry)
|
||||
const statementHits = Object.values(entry.s ?? {})
|
||||
const functionHits = Object.values(entry.f ?? {})
|
||||
const branchHits = Object.values(entry.b ?? {}).flat()
|
||||
|
||||
return {
|
||||
lines: {
|
||||
covered: Object.values(lineHits).filter(count => count > 0).length,
|
||||
total: Object.keys(lineHits).length,
|
||||
},
|
||||
statements: {
|
||||
covered: statementHits.filter(count => count > 0).length,
|
||||
total: statementHits.length,
|
||||
},
|
||||
functions: {
|
||||
covered: functionHits.filter(count => count > 0).length,
|
||||
total: functionHits.length,
|
||||
},
|
||||
branches: {
|
||||
covered: branchHits.filter(count => count > 0).length,
|
||||
total: branchHits.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function sumCoverageStats(rows) {
|
||||
const total = createEmptyCoverageStats()
|
||||
for (const row of rows)
|
||||
addCoverageStats(total, row)
|
||||
return total
|
||||
}
|
||||
|
||||
export function mergeCoverageStats(map, moduleName, stats) {
|
||||
const existing = map.get(moduleName) ?? createEmptyCoverageStats()
|
||||
addCoverageStats(existing, stats)
|
||||
map.set(moduleName, existing)
|
||||
}
|
||||
|
||||
export function percentage(covered, total) {
|
||||
if (total === 0)
|
||||
return 100
|
||||
return (covered / total) * 100
|
||||
}
|
||||
|
||||
export function formatPercent(metric) {
|
||||
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function createEmptyCoverageStats() {
|
||||
return {
|
||||
lines: { covered: 0, total: 0 },
|
||||
statements: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function addCoverageStats(target, source) {
|
||||
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
|
||||
target[metric].covered += source[metric].covered
|
||||
target[metric].total += source[metric].total
|
||||
}
|
||||
}
|
||||
|
||||
function walkComponentSourceFiles(currentDir, onFile) {
|
||||
if (!fs.existsSync(currentDir))
|
||||
return
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__' || entry.name === '__mocks__')
|
||||
continue
|
||||
walkComponentSourceFiles(entryPath, onFile)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/\.(?:ts|tsx)$/.test(entry.name) || isTestLikePath(entry.name))
|
||||
continue
|
||||
|
||||
onFile(entryPath)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
// Floors were set from the app/components baseline captured on 2026-03-13,
|
||||
// with a small buffer to avoid CI noise on existing code.
|
||||
export const EXCLUDED_COMPONENT_MODULES = new Set([
|
||||
'devtools',
|
||||
'provider',
|
||||
])
|
||||
|
||||
export const COMPONENTS_GLOBAL_THRESHOLDS = {
|
||||
lines: 58,
|
||||
statements: 58,
|
||||
functions: 58,
|
||||
branches: 54,
|
||||
}
|
||||
|
||||
export const COMPONENT_MODULE_THRESHOLDS = {
|
||||
'app': {
|
||||
lines: 45,
|
||||
statements: 45,
|
||||
functions: 50,
|
||||
branches: 35,
|
||||
},
|
||||
'app-sidebar': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'apps': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 85,
|
||||
branches: 80,
|
||||
},
|
||||
'base': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 90,
|
||||
branches: 95,
|
||||
},
|
||||
'billing': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'custom': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'datasets': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'develop': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'explore': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 85,
|
||||
},
|
||||
'goto-anything': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 90,
|
||||
},
|
||||
'header': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'plugins': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 85,
|
||||
},
|
||||
'rag-pipeline': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'share': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'signin': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'tools': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 90,
|
||||
branches: 90,
|
||||
},
|
||||
'workflow': {
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 10,
|
||||
branches: 10,
|
||||
},
|
||||
'workflow-app': {
|
||||
lines: 20,
|
||||
statements: 20,
|
||||
functions: 25,
|
||||
branches: 15,
|
||||
},
|
||||
}
|
||||
|
||||
export function getComponentModuleThreshold(moduleName) {
|
||||
return COMPONENT_MODULE_THRESHOLDS[moduleName] ?? null
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs'
|
||||
import {
|
||||
collectTrackedComponentSourceFiles,
|
||||
createComponentCoverageContext,
|
||||
formatPercent,
|
||||
getCoverageStats,
|
||||
getModuleName,
|
||||
loadTrackedCoverageEntries,
|
||||
mergeCoverageStats,
|
||||
percentage,
|
||||
sumCoverageStats,
|
||||
} from './components-coverage-common.mjs'
|
||||
import {
|
||||
COMPONENTS_GLOBAL_THRESHOLDS,
|
||||
EXCLUDED_COMPONENT_MODULES,
|
||||
getComponentModuleThreshold,
|
||||
} from './components-coverage-thresholds.mjs'
|
||||
|
||||
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
|
||||
|
||||
const repoRoot = repoRootFromCwd()
|
||||
const context = createComponentCoverageContext(repoRoot)
|
||||
const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json')
|
||||
|
||||
if (!fs.existsSync(coverageFinalPath)) {
|
||||
console.error(`Coverage report not found at ${coverageFinalPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
|
||||
const trackedSourceFiles = collectTrackedComponentSourceFiles(context)
|
||||
const coverageEntries = loadTrackedCoverageEntries(coverage, context)
|
||||
const fileCoverageRows = []
|
||||
const moduleCoverageMap = new Map()
|
||||
|
||||
for (const [file, entry] of coverageEntries.entries()) {
|
||||
const stats = getCoverageStats(entry)
|
||||
const moduleName = getModuleName(file)
|
||||
fileCoverageRows.push({ file, moduleName, ...stats })
|
||||
mergeCoverageStats(moduleCoverageMap, moduleName, stats)
|
||||
}
|
||||
|
||||
const overallCoverage = sumCoverageStats(fileCoverageRows)
|
||||
const overallTargetGaps = getTargetGaps(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
|
||||
const moduleCoverageRows = [...moduleCoverageMap.entries()]
|
||||
.map(([moduleName, stats]) => ({
|
||||
moduleName,
|
||||
stats,
|
||||
targets: getComponentModuleThreshold(moduleName),
|
||||
}))
|
||||
.map(row => ({
|
||||
...row,
|
||||
targetGaps: row.targets ? getTargetGaps(row.stats, row.targets) : [],
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aWorst = Math.min(...a.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY)
|
||||
const bWorst = Math.min(...b.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY)
|
||||
return aWorst - bWorst || a.moduleName.localeCompare(b.moduleName)
|
||||
})
|
||||
|
||||
appendSummary(buildSummary({
|
||||
coverageEntriesCount: coverageEntries.size,
|
||||
moduleCoverageRows,
|
||||
overallCoverage,
|
||||
overallTargetGaps,
|
||||
trackedSourceFilesCount: trackedSourceFiles.length,
|
||||
}))
|
||||
|
||||
function buildSummary({
|
||||
coverageEntriesCount,
|
||||
moduleCoverageRows,
|
||||
overallCoverage,
|
||||
overallTargetGaps,
|
||||
trackedSourceFilesCount,
|
||||
}) {
|
||||
const lines = [
|
||||
'### app/components Baseline Coverage',
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
`Coverage entries: ${coverageEntriesCount}/${trackedSourceFilesCount} tracked source files`,
|
||||
'',
|
||||
'| Metric | Current | Target | Delta |',
|
||||
'|---|---:|---:|---:|',
|
||||
`| Lines | ${formatPercent(overallCoverage.lines)} | ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% | ${formatDelta(overallCoverage.lines, COMPONENTS_GLOBAL_THRESHOLDS.lines)} |`,
|
||||
`| Statements | ${formatPercent(overallCoverage.statements)} | ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% | ${formatDelta(overallCoverage.statements, COMPONENTS_GLOBAL_THRESHOLDS.statements)} |`,
|
||||
`| Functions | ${formatPercent(overallCoverage.functions)} | ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% | ${formatDelta(overallCoverage.functions, COMPONENTS_GLOBAL_THRESHOLDS.functions)} |`,
|
||||
`| Branches | ${formatPercent(overallCoverage.branches)} | ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% | ${formatDelta(overallCoverage.branches, COMPONENTS_GLOBAL_THRESHOLDS.branches)} |`,
|
||||
'',
|
||||
]
|
||||
|
||||
if (coverageEntriesCount !== trackedSourceFilesCount) {
|
||||
lines.push('Warning: coverage report did not include every tracked component source file. CI should set `VITEST_COVERAGE_SCOPE=app-components` before collecting coverage.')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (overallTargetGaps.length > 0) {
|
||||
lines.push('Below baseline targets:')
|
||||
for (const gap of overallTargetGaps)
|
||||
lines.push(`- overall ${gap.metric}: ${gap.actual.toFixed(2)}% < ${gap.target}%`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('<details><summary>Module baseline coverage</summary>')
|
||||
lines.push('')
|
||||
lines.push('| Module | Lines | Statements | Functions | Branches | Targets | Status |')
|
||||
lines.push('|---|---:|---:|---:|---:|---|---|')
|
||||
for (const row of moduleCoverageRows) {
|
||||
const targetsLabel = row.targets
|
||||
? `L${row.targets.lines}/S${row.targets.statements}/F${row.targets.functions}/B${row.targets.branches}`
|
||||
: 'n/a'
|
||||
const status = row.targets
|
||||
? (row.targetGaps.length > 0 ? 'below-target' : 'at-target')
|
||||
: 'unconfigured'
|
||||
lines.push(`| ${row.moduleName} | ${percentage(row.stats.lines.covered, row.stats.lines.total).toFixed(2)}% | ${percentage(row.stats.statements.covered, row.stats.statements.total).toFixed(2)}% | ${percentage(row.stats.functions.covered, row.stats.functions.total).toFixed(2)}% | ${percentage(row.stats.branches.covered, row.stats.branches.total).toFixed(2)}% | ${targetsLabel} | ${status} |`)
|
||||
}
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
lines.push('Report only: baseline targets no longer gate CI. The blocking rule is the pure diff coverage step.')
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function getTargetGaps(stats, targets) {
|
||||
const gaps = []
|
||||
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
|
||||
const actual = percentage(stats[metric].covered, stats[metric].total)
|
||||
const target = targets[metric]
|
||||
const delta = actual - target
|
||||
if (delta < 0) {
|
||||
gaps.push({
|
||||
actual,
|
||||
delta,
|
||||
metric,
|
||||
target,
|
||||
})
|
||||
}
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
function formatDelta(metric, target) {
|
||||
const actual = percentage(metric.covered, metric.total)
|
||||
const delta = actual - target
|
||||
const sign = delta >= 0 ? '+' : ''
|
||||
return `${sign}${delta.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function appendSummary(lines) {
|
||||
const content = `${lines.join('\n')}\n`
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
|
||||
console.log(content)
|
||||
}
|
||||
|
||||
function repoRootFromCwd() {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import {
|
||||
buildGitDiffRevisionArgs,
|
||||
normalizeDiffRangeMode,
|
||||
resolveGitDiffContext,
|
||||
} from './check-components-diff-coverage-lib.mjs'
|
||||
import {
|
||||
createComponentCoverageContext,
|
||||
isAnyWebTestFile,
|
||||
isRelevantTestFile,
|
||||
isTrackedComponentSourceFile,
|
||||
} from './components-coverage-common.mjs'
|
||||
|
||||
const REQUESTED_DIFF_RANGE_MODE = normalizeDiffRangeMode(process.env.DIFF_RANGE_MODE)
|
||||
|
||||
const repoRoot = repoRootFromCwd()
|
||||
const context = createComponentCoverageContext(repoRoot)
|
||||
const baseSha = process.env.BASE_SHA?.trim()
|
||||
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
|
||||
|
||||
if (!baseSha || /^0+$/.test(baseSha)) {
|
||||
appendSummary([
|
||||
'### app/components Test Touch',
|
||||
'',
|
||||
'Skipped test-touch report because `BASE_SHA` was not available.',
|
||||
])
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const diffContext = resolveGitDiffContext({
|
||||
base: baseSha,
|
||||
head: headSha,
|
||||
mode: REQUESTED_DIFF_RANGE_MODE,
|
||||
execGit,
|
||||
})
|
||||
const changedFiles = getChangedFiles(diffContext)
|
||||
const changedSourceFiles = changedFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
|
||||
|
||||
if (changedSourceFiles.length === 0) {
|
||||
appendSummary([
|
||||
'### app/components Test Touch',
|
||||
'',
|
||||
...buildDiffContextSummary(diffContext),
|
||||
'',
|
||||
'No tracked source changes under `web/app/components/`. Test-touch report skipped.',
|
||||
])
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const changedRelevantTestFiles = changedFiles.filter(isRelevantTestFile)
|
||||
const changedOtherWebTestFiles = changedFiles.filter(filePath => isAnyWebTestFile(filePath) && !isRelevantTestFile(filePath))
|
||||
const totalChangedWebTests = [...new Set([...changedRelevantTestFiles, ...changedOtherWebTestFiles])]
|
||||
|
||||
appendSummary(buildSummary({
|
||||
changedOtherWebTestFiles,
|
||||
changedRelevantTestFiles,
|
||||
diffContext,
|
||||
changedSourceFiles,
|
||||
totalChangedWebTests,
|
||||
}))
|
||||
|
||||
function buildSummary({
|
||||
changedOtherWebTestFiles,
|
||||
changedRelevantTestFiles,
|
||||
diffContext,
|
||||
changedSourceFiles,
|
||||
totalChangedWebTests,
|
||||
}) {
|
||||
const lines = [
|
||||
'### app/components Test Touch',
|
||||
'',
|
||||
...buildDiffContextSummary(diffContext),
|
||||
'',
|
||||
`Tracked source files changed: ${changedSourceFiles.length}`,
|
||||
`Component-local or shared integration tests changed: ${changedRelevantTestFiles.length}`,
|
||||
`Other web tests changed: ${changedOtherWebTestFiles.length}`,
|
||||
`Total changed web tests: ${totalChangedWebTests.length}`,
|
||||
'',
|
||||
]
|
||||
|
||||
if (totalChangedWebTests.length === 0) {
|
||||
lines.push('Warning: no frontend test files changed alongside tracked component source changes.')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (changedRelevantTestFiles.length > 0) {
|
||||
lines.push('<details><summary>Changed component-local or shared tests</summary>')
|
||||
lines.push('')
|
||||
for (const filePath of changedRelevantTestFiles.slice(0, 40))
|
||||
lines.push(`- ${filePath.replace('web/', '')}`)
|
||||
if (changedRelevantTestFiles.length > 40)
|
||||
lines.push(`- ... ${changedRelevantTestFiles.length - 40} more`)
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (changedOtherWebTestFiles.length > 0) {
|
||||
lines.push('<details><summary>Changed other web tests</summary>')
|
||||
lines.push('')
|
||||
for (const filePath of changedOtherWebTestFiles.slice(0, 40))
|
||||
lines.push(`- ${filePath.replace('web/', '')}`)
|
||||
if (changedOtherWebTestFiles.length > 40)
|
||||
lines.push(`- ... ${changedOtherWebTestFiles.length - 40} more`)
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('Report only: test-touch is now advisory and no longer blocks the diff coverage gate.')
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildDiffContextSummary(diffContext) {
|
||||
const lines = [
|
||||
`Compared \`${diffContext.base.slice(0, 12)}\` -> \`${diffContext.head.slice(0, 12)}\``,
|
||||
]
|
||||
|
||||
if (diffContext.useCombinedMergeDiff) {
|
||||
lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``)
|
||||
lines.push(`Effective diff strategy: \`combined-merge\` (${diffContext.reason})`)
|
||||
}
|
||||
else if (diffContext.reason) {
|
||||
lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``)
|
||||
lines.push(`Effective diff range mode: \`${diffContext.mode}\` (${diffContext.reason})`)
|
||||
}
|
||||
else {
|
||||
lines.push(`Diff range mode: \`${diffContext.mode}\``)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function getChangedFiles(diffContext) {
|
||||
if (diffContext.useCombinedMergeDiff) {
|
||||
const output = execGit(['diff-tree', '--cc', '--no-commit-id', '--name-only', '-r', diffContext.head, '--', 'web'])
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', 'web'])
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function appendSummary(lines) {
|
||||
const content = `${lines.join('\n')}\n`
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
|
||||
console.log(content)
|
||||
}
|
||||
|
||||
function execGit(args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
function repoRootFromCwd() {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
}
|
||||
|
|
@ -7,24 +7,15 @@ import { defineConfig } from 'vite-plus'
|
|||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
|
||||
import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs'
|
||||
import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs'
|
||||
|
||||
const projectRoot = path.dirname(fileURLToPath(import.meta.url))
|
||||
const isCI = !!process.env.CI
|
||||
const coverageScope = process.env.VITEST_COVERAGE_SCOPE
|
||||
const browserInitializerInjectTarget = path.resolve(projectRoot, 'app/components/browser-initializer.tsx')
|
||||
const excludedAppComponentsCoveragePaths = [...EXCLUDED_COMPONENT_MODULES]
|
||||
.map(moduleName => `app/components/${moduleName}/**`)
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isTest = mode === 'test'
|
||||
const isStorybook = process.env.STORYBOOK === 'true'
|
||||
|| process.argv.some(arg => arg.toLowerCase().includes('storybook'))
|
||||
const isAppComponentsCoverage = coverageScope === 'app-components'
|
||||
const excludedComponentCoverageFiles = isAppComponentsCoverage
|
||||
? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' })
|
||||
: []
|
||||
|
||||
return {
|
||||
plugins: isTest
|
||||
|
|
@ -112,21 +103,6 @@ export default defineConfig(({ mode }) => {
|
|||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
||||
...(isAppComponentsCoverage
|
||||
? {
|
||||
include: ['app/components/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'app/components/**/*.d.ts',
|
||||
'app/components/**/*.spec.{ts,tsx}',
|
||||
'app/components/**/*.test.{ts,tsx}',
|
||||
'app/components/**/__tests__/**',
|
||||
'app/components/**/__mocks__/**',
|
||||
'app/components/**/*.stories.{ts,tsx}',
|
||||
...excludedComponentCoverageFiles,
|
||||
...excludedAppComponentsCoveragePaths,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue