dify/web/plugins/eslint/rules/require-title-for-truncated-text.test.js

317 lines
11 KiB
JavaScript

import assert from 'node:assert/strict'
import { resolve } from 'node:path'
import tsParser from '@typescript-eslint/parser'
import { Linter } from 'eslint'
import { it } from 'vitest'
import rule from './require-title-for-truncated-text.js'
import './fixtures/truncation.module.css'
const plugin = {
rules: {
'require-title-for-truncated-text': rule,
},
}
function verifyAndFix(code, filename = 'test.tsx') {
const linter = new Linter({ cwd: resolve('..') })
return linter.verifyAndFix(
code,
[
{
files: ['**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 'latest',
sourceType: 'module',
},
},
plugins: { dify: plugin },
rules: { 'dify/require-title-for-truncated-text': 'warn' },
},
],
{ filename },
)
}
it('accepts an explicit title and line-clamp-none', () => {
const result = verifyAndFix(`
const name = 'Dify'
export const Example = () => <>
<span className="truncate" title={name}>{name}</span>
<span className="line-clamp-none">{name}</span>
</>
`)
assert.equal(result.messages.length, 0)
assert.equal(result.fixed, false)
})
it('accepts Dify UI TooltipTrigger children and render elements', () => {
const result = verifyAndFix(`
import { TooltipTrigger as Trigger } from '@langgenius/dify-ui/tooltip'
const name = 'Dify'
export const Example = () => <>
<Trigger><span className="truncate">{name}</span></Trigger>
<Trigger render={<span className="line-clamp-2">{name}</span>} />
</>
`)
assert.equal(result.messages.length, 0)
assert.equal(result.fixed, false)
})
it('reports static classes, class helpers, constants, and inline styles without fixing', () => {
const code = `
const truncatedClassName = cn('min-w-0', condition && 'md:truncate')
export const Example = ({ itemName, label }) => <>
<span className={truncatedClassName}>{itemName}</span>
<p className={cn('line-clamp-2', condition && 'text-secondary')}>{label}</p>
<div style={{ textOverflow: 'ellipsis' }}>Description</div>
</>
`
const result = verifyAndFix(code)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 3)
assert.ok(result.messages.every((message) => message.severity === 1))
assert.equal(result.output, code)
})
it('resolves truncation variables from the active lexical scope', () => {
const shadowedParameterCode = `
const className = 'truncate'
const style = { textOverflow: 'ellipsis' }
export const Example = ({ className, style, label }) => <>
<span className={className}>{label}</span>
<span style={style}>{label}</span>
</>
`
const shadowedParameterResult = verifyAndFix(shadowedParameterCode)
assert.equal(shadowedParameterResult.fixed, false)
assert.equal(shadowedParameterResult.messages.length, 0)
assert.equal(shadowedParameterResult.output, shadowedParameterCode)
const nestedBindingResult = verifyAndFix(`
const className = 'regular'
const style = { color: 'red' }
export const Example = ({ label }) => {
const className = 'truncate'
const style = { textOverflow: 'ellipsis' }
return <>
<span className={className}>{label}</span>
<span style={style}>{label}</span>
</>
}
`)
assert.equal(nestedBindingResult.fixed, false)
assert.equal(nestedBindingResult.messages.length, 2)
assert.ok(nestedBindingResult.messages.every((message) => message.severity === 1))
})
it('reports an empty title without replacing it', () => {
const code = `
export const Example = ({ name }) => <span className="truncate" title="">{name}</span>
`
const result = verifyAndFix(code)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 1)
assert.equal(result.messages[0].severity, 1)
assert.equal(result.messages[0].messageId, 'emptyTitle')
assert.equal(result.output, code)
})
it('reports custom components without fixing them', () => {
const code = `
export const Example = ({ name }) => (
<CustomText className="truncate">{name}</CustomText>
)
`
const result = verifyAndFix(code)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 1)
assert.equal(result.messages[0].severity, 1)
assert.equal(result.output, code)
})
it('ignores expressions that may execute user code', () => {
const code = `
export const Example = ({ item }) => {
let index = 0
return <>
<span className="truncate">{item.name}</span>
<span className="truncate" title="">{getLabel()}</span>
<span className="truncate">{index++}</span>
<Markdown className="line-clamp-2" content={getContent()} />
</>
}
`
const result = verifyAndFix(code)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 0)
assert.equal(result.output, code)
})
it('reports a single nested text child or a text-bearing prop without fixing', () => {
const result = verifyAndFix(`
export const Example = ({ name, content }) => <>
<div className="truncate"><span>{name}</span></div>
<Markdown className="line-clamp-2" content={content} />
</>
`)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 2)
assert.ok(result.messages.every((message) => message.severity === 1))
})
it('checks Dify UI single text but ignores multiple text children', () => {
const filename = resolve('../packages/dify-ui/src/example.tsx')
const result = verifyAndFix(
`
export const Example = ({ primary, secondary }) => <>
<span className="truncate">{primary}</span>
<span className="truncate">{primary}{secondary}</span>
<div className="line-clamp-2">
<span>{primary}</span>
<span>{secondary}</span>
</div>
</>
`,
filename,
)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 1)
assert.equal(result.messages[0].severity, 1)
})
it('does not count non-text conditional children as additional text', () => {
const result = verifyAndFix(`
export const Example = ({ primary, showOverlay }) => (
<span className="truncate">
{primary}
{showOverlay && <span aria-hidden />}
</span>
)
`)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 1)
assert.equal(result.messages[0].messageId, 'missingTitle')
})
it('resolves truncation classes imported from CSS modules', () => {
const filename = resolve('plugins/eslint/rules/fixtures/example.tsx')
const result = verifyAndFix(
`
import styles from './truncation.module.css'
export const Example = ({ name }) => <>
<span className={styles.singleLine}>{name}</span>
<span className={styles.multipleLines}>{name}</span>
<span className={styles.noClamp}>{name}</span>
<span className={styles.zeroClamp}>{name}</span>
<span className={styles.unsetClamp}>{name}</span>
<span className={styles.regular}>{name}</span>
</>
export const Shadowed = ({ styles, name }) => (
<span className={styles.singleLine}>{name}</span>
)
`,
filename,
)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 2)
assert.ok(result.messages.every((message) => message.severity === 1))
})
it('reports dynamic truncation class and style expressions without fixing', () => {
const code = `
const regularClassName = 'regular'
const dynamicClassNames = ['regular']
const classMap = { regular: true }
const regularStyle = { color: 'red' }
const styles = [regularStyle]
const tag = strings => strings[0]
export const Example = ({ condition, name }) => <>
<span className={\`regular \${condition ? 'truncate' : regularClassName}\`}>{name}</span>
<span className={tag\`md:line-clamp-2\`}>{name}</span>
<span className={cn(...dynamicClassNames, new String('truncate'))}>{name}</span>
<span className={condition ? regularClassName : 'truncate'}>{name}</span>
<span className={condition && (regularClassName + ' truncate')}>{name}</span>
<span className={[...dynamicClassNames, 'truncate']}>{name}</span>
<span className={{ ...classMap, ['truncate']: condition }}>{name}</span>
<span className={(regularClassName, 'truncate')}>{name}</span>
<span style={condition ? regularStyle : { ['text-overflow']: 'ellipsis' }}>{name}</span>
<span style={condition && { WebkitLineClamp: 2 }}>{name}</span>
<span style={[...styles, { lineClamp: '3' }]}>{name}</span>
<span style={{ lineClamp: undefined }}>{name}</span>
<span style={{ WebkitLineClamp: 0 }}>{name}</span>
<span style={{ webkitLineClamp: 'none' }}>{name}</span>
</>
`
const result = verifyAndFix(code)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 11)
assert.ok(result.messages.every((message) => message.messageId === 'missingTitle'))
assert.equal(result.output, code)
})
it('reports statically empty title forms without replacing them', () => {
const code = `
export const Example = ({ name }) => <>
<span className="truncate" title>{name}</span>
<span className="truncate" title={null}>{name}</span>
<span className="truncate" title={undefined}>{name}</span>
<span className="truncate" title={\`\`}>{name}</span>
<span className="truncate" title={' '}>{name}</span>
</>
`
const result = verifyAndFix(code)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 5)
assert.ok(result.messages.every((message) => message.messageId === 'emptyTitle'))
assert.equal(result.output, code)
})
it('reports safe literal content but accepts a titled single child', () => {
const result = verifyAndFix(`
export const Example = ({ name }) => <>
<span className="truncate">{42}</span>
<span className="truncate">{\`Dify\`}</span>
<span className="truncate" title={/* intentionally empty */}>{name}</span>
<div className="truncate"><span title={name}>{name}</span></div>
</>
`)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 3)
assert.deepEqual(
result.messages.map((message) => message.messageId),
['missingTitle', 'missingTitle', 'emptyTitle'],
)
})
it('accepts referenced TooltipTrigger render candidates', () => {
const result = verifyAndFix(`
import { TooltipTrigger as Trigger } from '@langgenius/dify-ui/tooltip'
export const Example = ({ condition, name }) => {
const primaryTrigger = <span className="truncate">{name}</span>
const fallbackTrigger = <span className="line-clamp-2">{name}</span>
return <Trigger render={condition && (condition ? primaryTrigger : fallbackTrigger)} />
}
`)
assert.equal(result.fixed, false)
assert.equal(result.messages.length, 0)
})