From fdca3e2a1a1a03b0ae0c2dd95238de916f16839d Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 1 Sep 2026 06:18:53 +0000 Subject: [PATCH] chore: enforce titles on CSS-truncated elements (#41438) --- AGENTS.md | 6 + lint.config.ts | 13 + .../__tests__/access-point-card.spec.tsx | 5 + .../access-point/shared/access-point-card.tsx | 6 +- .../index.tsx | 5 +- .../remove-annotation-confirm-modal/index.tsx | 5 +- .../workflow-tool-action/index.tsx | 5 +- .../app/deploy/shared/deployment-status.tsx | 4 +- .../app/overview/__tests__/app-chart.spec.tsx | 2 +- web/app/components/app/overview/app-chart.tsx | 5 +- .../external-api/external-api-modal/index.tsx | 5 +- .../rag-pipeline/components/conversion.tsx | 5 +- .../components/remove-effect-var-confirm.tsx | 5 +- .../__tests__/large-data-alert.spec.tsx | 10 +- .../variable-inspect/large-data-alert.tsx | 4 +- web/docs/truncated-text-disclosure.md | 73 ++ .../__tests__/access-surface-card.spec.tsx | 1 + .../access/components/access-surface-card.tsx | 6 +- .../advanced/content-moderation.tsx | 4 +- .../agent-v2/roster/__tests__/page.spec.tsx | 5 +- web/features/agent-v2/roster/page.tsx | 1 + web/plugins/eslint/index.js | 2 + .../rules/fixtures/truncation.module.css | 23 + .../rules/require-title-for-truncated-text.js | 743 ++++++++++++++++++ .../require-title-for-truncated-text.test.js | 316 ++++++++ 25 files changed, 1242 insertions(+), 17 deletions(-) create mode 100644 web/docs/truncated-text-disclosure.md create mode 100644 web/plugins/eslint/rules/fixtures/truncation.module.css create mode 100644 web/plugins/eslint/rules/require-title-for-truncated-text.js create mode 100644 web/plugins/eslint/rules/require-title-for-truncated-text.test.js diff --git a/AGENTS.md b/AGENTS.md index 6f5673b316a..d7b7320aa2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,9 @@ Dify is an open-source platform for building LLM applications, agentic workflows - Run backend commands through `uv run --project api `. - Backend integration tests are CI-only and are not expected to run locally. - Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files. + +## Frontend Workflow + +- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure]. + +[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md diff --git a/lint.config.ts b/lint.config.ts index b7689553430..2528c3e7c38 100644 --- a/lint.config.ts +++ b/lint.config.ts @@ -1117,6 +1117,19 @@ export const lintConfig = { 'eslint-react/use-memo': 'error', }, }, + { + files: ['web/**/*.{jsx,tsx}'], + excludeFiles: [ + 'web/**/__tests__/**', + 'web/**/*.spec.{jsx,tsx}', + 'web/**/*.test.{jsx,tsx}', + 'web/**/*.stories.{jsx,tsx}', + 'web/**/*.story.{jsx,tsx}', + ], + rules: { + 'dify/require-title-for-truncated-text': 'warn', + }, + }, { files: [ 'web/**/__tests__/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}', diff --git a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx index 5f740babef4..c800deded33 100644 --- a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx @@ -21,6 +21,11 @@ describe('AccessPointCard', () => { 'data-highlighted', 'true', ) + expect(screen.getByRole('heading', { name: 'Web App' })).toHaveAttribute('title', 'Web App') + expect(screen.getByText('Web application access')).toHaveAttribute( + 'title', + 'Web application access', + ) }) it.each<[AccessPointStatus, string, boolean]>([ diff --git a/web/app/components/app/access-point/shared/access-point-card.tsx b/web/app/components/app/access-point/shared/access-point-card.tsx index 664b21a0500..3e4430dea85 100644 --- a/web/app/components/app/access-point/shared/access-point-card.tsx +++ b/web/app/components/app/access-point/shared/access-point-card.tsx @@ -72,10 +72,12 @@ export function AccessPointCard({ icon )} -

+

{title}

- {description} + + {description} +
{showStatus && ( <> diff --git a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx index 5cbd69dfc6a..48943656d07 100644 --- a/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/clear-all-annotations-confirm-modal/index.tsx @@ -26,7 +26,10 @@ const ClearAllAnnotationsConfirmModal: FC = ({ isShow, onHide, onConfirm !open && onHide()}>
- + {title}
diff --git a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx index 55e347dfdfa..dbaa091659f 100644 --- a/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx +++ b/web/app/components/app/annotation/remove-annotation-confirm-modal/index.tsx @@ -25,7 +25,10 @@ const RemoveAnnotationConfirmModal: FC = ({ isShow, onHide, onRemove }) = !open && onHide()}>
- + {title}
diff --git a/web/app/components/app/app-publisher/workflow-tool-action/index.tsx b/web/app/components/app/app-publisher/workflow-tool-action/index.tsx index aeed4d0bf54..bf9cc3fe444 100644 --- a/web/app/components/app/app-publisher/workflow-tool-action/index.tsx +++ b/web/app/components/app/app-publisher/workflow-tool-action/index.tsx @@ -74,7 +74,10 @@ const WorkflowToolAction = ({
- + {workflowToolLabel} diff --git a/web/app/components/app/deploy/shared/deployment-status.tsx b/web/app/components/app/deploy/shared/deployment-status.tsx index de7cac3dc53..4f4aabd2076 100644 --- a/web/app/components/app/deploy/shared/deployment-status.tsx +++ b/web/app/components/app/deploy/shared/deployment-status.tsx @@ -68,7 +68,9 @@ export function DeploymentStatus({ status }: { status?: DeploymentStatusValue }) ) : ( )} - {label} + + {label} + ) } diff --git a/web/app/components/app/overview/__tests__/app-chart.spec.tsx b/web/app/components/app/overview/__tests__/app-chart.spec.tsx index aa0e503ff09..07b0b666996 100644 --- a/web/app/components/app/overview/__tests__/app-chart.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-chart.spec.tsx @@ -40,7 +40,7 @@ describe('app-chart', () => { />, ) - expect(screen.getByText('Cost title'))!.toBeInTheDocument() + expect(screen.getByText('Cost title')).toHaveAttribute('title', 'Cost title') expect(screen.getByText('300'))!.toBeInTheDocument() expect(screen.queryByText('Last 7 days'))!.not.toBeInTheDocument() expect(screen.getByText(/\$3\.7500/))!.toBeInTheDocument() diff --git a/web/app/components/app/overview/app-chart.tsx b/web/app/components/app/overview/app-chart.tsx index 7596c7140ec..72f881a71c3 100644 --- a/web/app/components/app/overview/app-chart.tsx +++ b/web/app/components/app/overview/app-chart.tsx @@ -96,7 +96,10 @@ const Chart: React.FC = ({ >
-
+
{title}
{explanation && ( diff --git a/web/app/components/datasets/external-api/external-api-modal/index.tsx b/web/app/components/datasets/external-api/external-api-modal/index.tsx index 21567090075..2b1e39a7075 100644 --- a/web/app/components/datasets/external-api/external-api-modal/index.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/index.tsx @@ -246,7 +246,10 @@ const AddExternalAPIModal: FC = ({ >
- + Warning diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index 0e873ff1990..2c2b6d6077e 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -119,7 +119,10 @@ const Conversion = () => { >
- + {confirmTitle} diff --git a/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx b/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx index 1da22c8f4ec..4560813f628 100644 --- a/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx +++ b/web/app/components/workflow/nodes/_base/components/remove-effect-var-confirm.tsx @@ -28,7 +28,10 @@ const RemoveVarConfirm: FC = ({ isShow, onConfirm, onCancel }) => { !open && onCancel()}>
- + {title} diff --git a/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx index d2a28ffe225..45938af5124 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/large-data-alert.spec.tsx @@ -7,7 +7,10 @@ describe('LargeDataAlert', () => { , ) - expect(screen.getByText('workflow.debug.variableInspect.largeData')).toBeInTheDocument() + expect(screen.getByText('workflow.debug.variableInspect.largeData')).toHaveAttribute( + 'title', + 'workflow.debug.variableInspect.largeData', + ) expect(screen.getByText('workflow.debug.variableInspect.export')).toBeInTheDocument() expect(container.firstChild).toHaveClass('extra-alert') }) @@ -15,7 +18,10 @@ describe('LargeDataAlert', () => { it('should render the no-export message and omit the export action when the URL is missing', () => { render() - expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toBeInTheDocument() + expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toHaveAttribute( + 'title', + 'workflow.debug.variableInspect.largeDataNoExport', + ) expect(screen.queryByText('workflow.debug.variableInspect.export')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/variable-inspect/large-data-alert.tsx b/web/app/components/workflow/variable-inspect/large-data-alert.tsx index 540b45665bc..134aadc9366 100644 --- a/web/app/components/workflow/variable-inspect/large-data-alert.tsx +++ b/web/app/components/workflow/variable-inspect/large-data-alert.tsx @@ -25,7 +25,9 @@ const LargeDataAlert: FC = ({ textHasNoExport, downloadUrl, className }) >
-
{text}
+
+ {text} +
{downloadUrl && (
diff --git a/web/docs/truncated-text-disclosure.md b/web/docs/truncated-text-disclosure.md new file mode 100644 index 00000000000..cb159e2cfbc --- /dev/null +++ b/web/docs/truncated-text-disclosure.md @@ -0,0 +1,73 @@ +# Truncated Text Disclosure + +Treat native `title` as an opt-in, supplemental product behavior. Do not treat it as a mechanical companion to `truncate`, `text-overflow`, or `line-clamp-*`. + +Missing `title` is not by itself an accessibility defect. Native title tooltips must not: + +- be the only way to access essential information; +- replace a visible label, accessible name, or accessible description; +- compete with an existing hover, focus, pointer, expand, copy, or detail interaction. + +Before making a change: + +1. Trace the value to the final rendered DOM element. +2. Identify the existing owner of full-content disclosure. +3. Classify the candidate as `AUTO`, `COVERED`, `SKIP`, or `REVIEW`. +4. Modify only `AUTO` candidates. Report the others without changing code. + +## AUTO + +Automatically add `title` only when every condition below is satisfied: + +- The target is a native, non-editable, pointer-reachable text container whose own hit area receives the pointer, or a documented component that forwards `title` unchanged to that final DOM element. +- The final element intentionally implements single-line truncation. A truncation-related class alone is not sufficient evidence. +- The full content is a bounded, non-sensitive, single-line plain string. +- “Bounded” means fixed text, an enum, or a value with an explicit owner-level maximum length. +- The exact already-evaluated display value can be reused without repeating a function call, getter, conversion, mutation, async operation, or other potentially effectful expression. +- The final rendered element does not already receive an equivalent title through its props, wrapper, child component, or covering interaction target. +- No Tooltip, PreviewCard, Popover, expandable content, “show more” action, detail view, copy/reveal action, or other full-content owner exists. +- The native tooltip will not compete with another hover, focus, pointer, or keyboard interaction. + +## COVERED + +Classify as `COVERED` and make no change when another component or interaction already owns full-content disclosure, including: + +- Tooltip, PreviewCard, or Popover; +- expandable or “show more” content; +- a detail view opened from the current surface; +- a copy or reveal action; +- a parent or covering interaction target that already provides the equivalent title. + +## SKIP + +Classify as `SKIP` and make no change when any condition below applies: + +- The value contains or may contain a secret, token, API key, password, credential, or other sensitive data. +- The value is unbounded user content, multiline content, a comment, prompt, generated output, log body, or another potentially large string. +- The visible content is JSX, `ReactNode`, structured content, or would require a stringification helper. +- Producing the title would repeat or relocate evaluation of a function call, getter, conversion, mutation, async operation, or other potentially effectful expression. +- The element is an input, textarea, editable surface, `pointer-events-none`, covered by another element, or not the actual pointer target. +- An existing `title`, including `title=""`, would need to be overwritten or removed. +- The content uses `line-clamp-*` and already has an expand or detail interaction. + +## REVIEW + +Classify as `REVIEW`, make no code change, and report the reason when: + +- the correct disclosure owner is ambiguous or product-specific; +- the value bound or sensitivity cannot be proven; +- the final DOM element or pointer owner cannot be traced; +- the component is interactive; +- the content uses `line-clamp-*` without an existing full-content path; +- actual truncation or the intended product behavior cannot be established from the owner contract. + +Before adding `title` to a custom component, inspect its implementation and trace the prop to the final DOM element. A `value`, `label`, or similar prop may already provide the native title; do not add a duplicate at the call site. + +Do not introduce: + +- a repository-wide missing-title lint error; +- a mass autofix or migration; +- a `ReactNode`-to-string helper; +- suppressions for candidates outside the allowlist. + +Test the feature-owned disclosure behavior through its public interface. Assert native `title` only when it is an explicitly accepted product contract. Do not use `getByTitle` or `toHaveAttribute('title', ...)` merely to prove a migration, and do not use title-based selectors to test unrelated interactions. diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx index c6512469795..3e909668afd 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx @@ -42,6 +42,7 @@ describe('AccessSurfaceCard', () => { ) expect(screen.getByRole('article', { name: 'Web app' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Web app' })).toHaveAttribute('title', 'Web app') }) it('should copy the endpoint and render copied state from the clipboard hook', async () => { diff --git a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx index 55008713526..5f266ea0156 100644 --- a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx @@ -90,7 +90,11 @@ export function AccessSurfaceCard({ > -

+

{title}

{badge} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx index 44401bc3f6c..bd53a1f7ad7 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/content-moderation.tsx @@ -166,7 +166,9 @@ function AgentContentModerationSettingsContent() {
{t(($) => $['feature.moderation.contentEnableLabel'], { ns: 'appDebug' })}
-
{enabledContent}
+
+ {enabledContent} +
) : ( diff --git a/web/features/agent-v2/roster/__tests__/page.spec.tsx b/web/features/agent-v2/roster/__tests__/page.spec.tsx index bae86e97646..e1ac7fa07eb 100644 --- a/web/features/agent-v2/roster/__tests__/page.spec.tsx +++ b/web/features/agent-v2/roster/__tests__/page.spec.tsx @@ -129,7 +129,10 @@ describe('RosterPage', () => { it('uses the localized roster title for the page heading', () => { render() - expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'agentV2.roster.title' })).toHaveAttribute( + 'title', + 'agentV2.roster.title', + ) expect(screen.getByRole('region', { name: 'agentV2.roster.title' })).toBeInTheDocument() }) diff --git a/web/features/agent-v2/roster/page.tsx b/web/features/agent-v2/roster/page.tsx index c36f3c3fd11..c7f13199c7c 100644 --- a/web/features/agent-v2/roster/page.tsx +++ b/web/features/agent-v2/roster/page.tsx @@ -112,6 +112,7 @@ export default function RosterPage() {

{pageTitle}

diff --git a/web/plugins/eslint/index.js b/web/plugins/eslint/index.js index 09af24bbf7b..2fd685cb217 100644 --- a/web/plugins/eslint/index.js +++ b/web/plugins/eslint/index.js @@ -2,6 +2,7 @@ import consistentPlaceholders from './rules/consistent-placeholders.js' import i18nFlatKey from './rules/i18n-flat-key.js' import noExtraKeys from './rules/no-extra-keys.js' import preferTailwindIcons from './rules/prefer-tailwind-icons.js' +import requireTitleForTruncatedText from './rules/require-title-for-truncated-text.js' /** @type {import('eslint').ESLint.Plugin} */ const plugin = { @@ -14,6 +15,7 @@ const plugin = { 'i18n-flat-key': i18nFlatKey, 'no-extra-keys': noExtraKeys, 'prefer-tailwind-icons': preferTailwindIcons, + 'require-title-for-truncated-text': requireTitleForTruncatedText, }, } diff --git a/web/plugins/eslint/rules/fixtures/truncation.module.css b/web/plugins/eslint/rules/fixtures/truncation.module.css new file mode 100644 index 00000000000..fb81f59ed30 --- /dev/null +++ b/web/plugins/eslint/rules/fixtures/truncation.module.css @@ -0,0 +1,23 @@ +.singleLine { + @apply truncate; +} + +.multipleLines { + -webkit-line-clamp: 2; +} + +.noClamp { + line-clamp: none; +} + +.zeroClamp { + -webkit-line-clamp: 0; +} + +.unsetClamp { + line-clamp: unset !important; +} + +.regular { + overflow: hidden; +} diff --git a/web/plugins/eslint/rules/require-title-for-truncated-text.js b/web/plugins/eslint/rules/require-title-for-truncated-text.js new file mode 100644 index 00000000000..d0c77ba52d9 --- /dev/null +++ b/web/plugins/eslint/rules/require-title-for-truncated-text.js @@ -0,0 +1,743 @@ +import { readFileSync, statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const DEFAULT_TRUNCATION_CLASSES = new Set(['text-ellipsis', 'truncate']) +const cssModuleClassCache = new Map() +const UNSAFE_TITLE_EXPRESSION = Symbol('unsafe-title-expression') +const STYLE_WRAPPER_TYPES = new Set([ + 'ChainExpression', + 'TSAsExpression', + 'TSInstantiationExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) + +function getJsxName(node) { + if (!node) return null + if (node.type === 'JSXIdentifier') return node.name + if (node.type === 'JSXMemberExpression') { + const objectName = getJsxName(node.object) + const propertyName = getJsxName(node.property) + return objectName && propertyName ? `${objectName}.${propertyName}` : null + } + if (node.type === 'JSXNamespacedName') { + const namespaceName = getJsxName(node.namespace) + const name = getJsxName(node.name) + return namespaceName && name ? `${namespaceName}:${name}` : null + } + return null +} + +function getAttribute(openingElement, attributeName) { + return openingElement.attributes.find( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === attributeName, + ) +} + +function unwrapExpression(node) { + let current = node + while (current && STYLE_WRAPPER_TYPES.has(current.type)) current = current.expression + return current +} + +function getStaticString(node) { + const current = unwrapExpression(node) + if (!current) return null + if (current.type === 'Literal' && typeof current.value === 'string') return current.value + if (current.type === 'TemplateLiteral' && current.expressions.length === 0) + return current.quasis[0]?.value.cooked ?? current.quasis[0]?.value.raw ?? '' + return null +} + +function getUnprefixedClassName(token) { + let bracketDepth = 0 + let lastVariantSeparator = -1 + + for (let index = 0; index < token.length; index++) { + const character = token[index] + if (character === '[' || character === '(') bracketDepth++ + else if (character === ']' || character === ')') bracketDepth = Math.max(0, bracketDepth - 1) + else if (character === ':' && bracketDepth === 0) lastVariantSeparator = index + } + + return token + .slice(lastVariantSeparator + 1) + .replace(/^!/u, '') + .replace(/!$/u, '') +} + +function isTruncationClassToken(token) { + const className = getUnprefixedClassName(token) + if (DEFAULT_TRUNCATION_CLASSES.has(className)) return true + if (className.startsWith('line-clamp-') && className !== 'line-clamp-none') return true + + const normalizedArbitraryClass = className.replaceAll(' ', '').toLowerCase() + return ( + normalizedArbitraryClass === '[text-overflow:ellipsis]' || + /^\[(?:-webkit-)?line-clamp:(?!none(?:\]|$))[^\]]+\]$/u.test(normalizedArbitraryClass) + ) +} + +function stringContainsTruncationClass(value) { + return value.split(/\s+/u).some((token) => token && isTruncationClassToken(token)) +} + +function hasActiveLineClampDeclaration(cssText) { + const lineClampPattern = /(?:-webkit-)?line-clamp\s*:\s*([^;}]+)/giu + return [...cssText.matchAll(lineClampPattern)].some((match) => { + const value = (match[1] ?? '').trim() + return value !== '' && !/^(?:0|none|unset)\b/iu.test(value) + }) +} + +function getTruncatingCssModuleClassNames(cssText) { + const classNames = new Set() + const classBlockPattern = /\.([A-Z_a-z][\w-]*)\s*\{([^{}]*)\}/gu + + for (const match of cssText.matchAll(classBlockPattern)) { + const [, className, body = ''] = match + const hasTextEllipsis = /text-overflow\s*:\s*ellipsis\b/iu.test(body) + const hasLineClamp = hasActiveLineClampDeclaration(body) + const hasTruncationApply = [...body.matchAll(/@apply\s+([^;}]+)/gu)].some((applyMatch) => + stringContainsTruncationClass(applyMatch[1] ?? ''), + ) + + if (hasTextEllipsis || hasLineClamp || hasTruncationApply) classNames.add(className) + } + + return classNames +} + +function getCssModuleClassNames(cssModulePath) { + try { + const modifiedTime = statSync(cssModulePath).mtimeMs + const cached = cssModuleClassCache.get(cssModulePath) + if (cached?.modifiedTime === modifiedTime) return cached.classNames + + const classNames = getTruncatingCssModuleClassNames(readFileSync(cssModulePath, 'utf8')) + cssModuleClassCache.set(cssModulePath, { classNames, modifiedTime }) + return classNames + } catch { + return new Set() + } +} + +function getPropertyName(property) { + if (!property || property.type !== 'Property') return null + if (!property.computed && property.key.type === 'Identifier') return property.key.name + if (property.key.type === 'Literal' && typeof property.key.value === 'string') + return property.key.value + return null +} + +function getVariableByName(scope, name) { + let currentScope = scope + while (currentScope) { + const variable = currentScope.set.get(name) + if (variable) return variable + currentScope = currentScope.upper + } + return null +} + +function getVariableInitializer(identifier, sourceCode) { + const variable = getVariableByName(sourceCode.getScope(identifier), identifier.name) + if (!variable || variable.defs.length !== 1) return null + + const [definition] = variable.defs + if ( + definition.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + definition.node.id.type !== 'Identifier' || + !definition.node.init + ) + return null + + return { initializer: definition.node.init, variable } +} + +function expressionContainsTruncationClass( + node, + sourceCode, + cssModuleBindings, + seenVariables = new Set(), +) { + const current = unwrapExpression(node) + if (!current) return false + + const staticString = getStaticString(current) + if (staticString !== null) return stringContainsTruncationClass(staticString) + + switch (current.type) { + case 'Identifier': { + const resolvedVariable = getVariableInitializer(current, sourceCode) + if (!resolvedVariable || seenVariables.has(resolvedVariable.variable)) return false + const nextSeenVariables = new Set(seenVariables) + nextSeenVariables.add(resolvedVariable.variable) + return expressionContainsTruncationClass( + resolvedVariable.initializer, + sourceCode, + cssModuleBindings, + nextSeenVariables, + ) + } + case 'MemberExpression': { + const object = unwrapExpression(current.object) + if (object?.type !== 'Identifier') return false + const propertyName = current.computed + ? getStaticString(current.property) + : current.property.type === 'Identifier' + ? current.property.name + : null + const variable = getVariableByName(sourceCode.getScope(object), object.name) + return propertyName !== null && cssModuleBindings.get(variable)?.has(propertyName) + } + case 'TemplateLiteral': + return ( + current.quasis.some((quasi) => + stringContainsTruncationClass(quasi.value.cooked ?? quasi.value.raw), + ) || + current.expressions.some((expression) => + expressionContainsTruncationClass( + expression, + sourceCode, + cssModuleBindings, + seenVariables, + ), + ) + ) + case 'TaggedTemplateExpression': + return expressionContainsTruncationClass( + current.quasi, + sourceCode, + cssModuleBindings, + seenVariables, + ) + case 'CallExpression': + case 'NewExpression': + return ( + expressionContainsTruncationClass( + current.callee, + sourceCode, + cssModuleBindings, + seenVariables, + ) || + current.arguments.some((argument) => + argument.type === 'SpreadElement' + ? expressionContainsTruncationClass( + argument.argument, + sourceCode, + cssModuleBindings, + seenVariables, + ) + : expressionContainsTruncationClass( + argument, + sourceCode, + cssModuleBindings, + seenVariables, + ), + ) + ) + case 'ConditionalExpression': + return ( + expressionContainsTruncationClass( + current.consequent, + sourceCode, + cssModuleBindings, + seenVariables, + ) || + expressionContainsTruncationClass( + current.alternate, + sourceCode, + cssModuleBindings, + seenVariables, + ) + ) + case 'LogicalExpression': + case 'BinaryExpression': + return ( + expressionContainsTruncationClass( + current.left, + sourceCode, + cssModuleBindings, + seenVariables, + ) || + expressionContainsTruncationClass( + current.right, + sourceCode, + cssModuleBindings, + seenVariables, + ) + ) + case 'ArrayExpression': + return current.elements.some( + (element) => + element && + (element.type === 'SpreadElement' + ? expressionContainsTruncationClass( + element.argument, + sourceCode, + cssModuleBindings, + seenVariables, + ) + : expressionContainsTruncationClass( + element, + sourceCode, + cssModuleBindings, + seenVariables, + )), + ) + case 'ObjectExpression': + return current.properties.some((property) => { + if (property.type === 'SpreadElement') + return expressionContainsTruncationClass( + property.argument, + sourceCode, + cssModuleBindings, + seenVariables, + ) + + const propertyName = getPropertyName(property) + return ( + (propertyName !== null && stringContainsTruncationClass(propertyName)) || + expressionContainsTruncationClass( + property.value, + sourceCode, + cssModuleBindings, + seenVariables, + ) + ) + }) + case 'SequenceExpression': + return current.expressions.some((expression) => + expressionContainsTruncationClass(expression, sourceCode, cssModuleBindings, seenVariables), + ) + default: + return false + } +} + +function isActiveLineClampValue(node) { + const current = unwrapExpression(node) + if (!current) return false + if (current.type === 'Literal') { + if (current.value === null || current.value === 0) return false + if (typeof current.value === 'string') + return !['', '0', 'none', 'unset'].includes(current.value.trim().toLowerCase()) + return true + } + return current.type !== 'Identifier' || current.name !== 'undefined' +} + +function expressionContainsTruncationStyle(node, sourceCode, seenVariables = new Set()) { + const current = unwrapExpression(node) + if (!current) return false + + if (current.type === 'Identifier') { + const resolvedVariable = getVariableInitializer(current, sourceCode) + if (!resolvedVariable || seenVariables.has(resolvedVariable.variable)) return false + const nextSeenVariables = new Set(seenVariables) + nextSeenVariables.add(resolvedVariable.variable) + return expressionContainsTruncationStyle( + resolvedVariable.initializer, + sourceCode, + nextSeenVariables, + ) + } + + if (current.type === 'ConditionalExpression') { + return ( + expressionContainsTruncationStyle(current.consequent, sourceCode, seenVariables) || + expressionContainsTruncationStyle(current.alternate, sourceCode, seenVariables) + ) + } + + if (current.type === 'LogicalExpression') { + return ( + expressionContainsTruncationStyle(current.left, sourceCode, seenVariables) || + expressionContainsTruncationStyle(current.right, sourceCode, seenVariables) + ) + } + + if (current.type === 'ArrayExpression') { + return current.elements.some( + (element) => + element && + expressionContainsTruncationStyle( + element.type === 'SpreadElement' ? element.argument : element, + sourceCode, + seenVariables, + ), + ) + } + + if (current.type !== 'ObjectExpression') return false + + return current.properties.some((property) => { + if (property.type === 'SpreadElement') + return expressionContainsTruncationStyle(property.argument, sourceCode, seenVariables) + + const propertyName = getPropertyName(property) + if (propertyName === 'textOverflow' || propertyName === 'text-overflow') + return getStaticString(property.value)?.trim().toLowerCase() === 'ellipsis' + if ( + ['WebkitLineClamp', 'webkitLineClamp', 'lineClamp', '-webkit-line-clamp'].includes( + propertyName, + ) + ) + return isActiveLineClampValue(property.value) + return false + }) +} + +function hasTruncation(openingElement, sourceCode, cssModuleBindings) { + const classAttribute = + getAttribute(openingElement, 'className') ?? getAttribute(openingElement, 'class') + if (classAttribute?.value) { + if ( + classAttribute.value.type === 'Literal' && + typeof classAttribute.value.value === 'string' && + stringContainsTruncationClass(classAttribute.value.value) + ) + return true + if ( + classAttribute.value.type === 'JSXExpressionContainer' && + expressionContainsTruncationClass( + classAttribute.value.expression, + sourceCode, + cssModuleBindings, + ) + ) + return true + } + + const styleAttribute = getAttribute(openingElement, 'style') + return ( + styleAttribute?.value?.type === 'JSXExpressionContainer' && + expressionContainsTruncationStyle(styleAttribute.value.expression, sourceCode) + ) +} + +function isEmptyTitleAttribute(attribute) { + if (!attribute || !attribute.value) return true + if (attribute.value.type === 'Literal') + return typeof attribute.value.value !== 'string' || attribute.value.value.trim() === '' + if (attribute.value.type !== 'JSXExpressionContainer') return false + + const expression = unwrapExpression(attribute.value.expression) + if (!expression || expression.type === 'JSXEmptyExpression') return true + if (expression.type === 'Literal') + return expression.value === null || String(expression.value).trim() === '' + if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) + return (expression.quasis[0]?.value.cooked ?? '').trim() === '' + return expression.type === 'Identifier' && expression.name === 'undefined' +} + +function isRenderableTitleExpression(node) { + const current = unwrapExpression(node) + if (!current) return false + return ![ + 'ArrowFunctionExpression', + 'FunctionExpression', + 'JSXElement', + 'JSXEmptyExpression', + 'JSXFragment', + 'ObjectExpression', + 'SequenceExpression', + ].includes(current.type) +} + +function isSafeToDuplicateTitleExpression(node) { + const current = unwrapExpression(node) + if (!current) return false + + if (current.type === 'Identifier') return current.name !== 'undefined' + if (current.type === 'Literal') + return ( + current.value !== null && !['boolean', 'object', 'undefined'].includes(typeof current.value) + ) + if (current.type === 'TemplateLiteral' && current.expressions.length === 0) + return (current.quasis[0]?.value.cooked ?? '').trim() !== '' + return false +} + +function getMeaningfulChildren(element) { + return element.children.filter((child) => { + if (child.type === 'JSXText') return child.value.trim() !== '' + if (child.type === 'JSXExpressionContainer') + return child.expression.type !== 'JSXEmptyExpression' + return true + }) +} + +function getSingleTextChild(element) { + const meaningfulChildren = getMeaningfulChildren(element) + if (meaningfulChildren.length !== 1) return null + + const child = meaningfulChildren[0] + if (child.type === 'JSXElement') return getSingleTextChild(child) + return child +} + +function getExpressionTextChildCount(node) { + const current = unwrapExpression(node) + if (!current) return 0 + + if (current.type === 'JSXElement' || current.type === 'JSXFragment') + return getTextChildCount(current) + if (current.type === 'ConditionalExpression') + return Math.max( + getExpressionTextChildCount(current.consequent), + getExpressionTextChildCount(current.alternate), + ) + if (current.type === 'LogicalExpression') { + if (current.operator === '&&') return getExpressionTextChildCount(current.right) + return Math.max( + getExpressionTextChildCount(current.left), + getExpressionTextChildCount(current.right), + ) + } + if (current.type === 'ArrayExpression') { + let count = 0 + for (const element of current.elements) { + if (!element) continue + count += getExpressionTextChildCount( + element.type === 'SpreadElement' ? element.argument : element, + ) + if (count > 1) return count + } + return count + } + if (current.type === 'Literal') { + if (current.value === null || typeof current.value === 'boolean') return 0 + if (typeof current.value === 'string') return current.value.trim() === '' ? 0 : 1 + } + if (current.type === 'TemplateLiteral' && current.expressions.length === 0) + return (current.quasis[0]?.value.cooked ?? '').trim() === '' ? 0 : 1 + if (current.type === 'Identifier' && current.name === 'undefined') return 0 + return isRenderableTitleExpression(current) ? 1 : 0 +} + +function getTextChildCount(element) { + let count = 0 + + for (const child of getMeaningfulChildren(element)) { + if (child.type === 'JSXText') count++ + else if (child.type === 'JSXElement' || child.type === 'JSXFragment') + count += getTextChildCount(child) + else if (child.type === 'JSXExpressionContainer') + count += getExpressionTextChildCount(child.expression) + + if (count > 1) return count + } + + return count +} + +function hasMultipleTextChildren(openingElement) { + const element = openingElement.parent + return element?.type === 'JSXElement' && getTextChildCount(element) > 1 +} + +function getTitleFixTextFromAttribute(attribute, sourceCode) { + if (!attribute?.value) return null + if (attribute.value.type === 'Literal') { + if (typeof attribute.value.value !== 'string' || attribute.value.value.trim() === '') + return null + return `title=${JSON.stringify(attribute.value.value)}` + } + if (attribute.value.type !== 'JSXExpressionContainer') return null + if (!isSafeToDuplicateTitleExpression(attribute.value.expression)) return UNSAFE_TITLE_EXPRESSION + return `title={${sourceCode.getText(attribute.value.expression)}}` +} + +function getTitleFixText(openingElement, sourceCode) { + const element = openingElement.parent + if (!element || element.type !== 'JSXElement') return null + + const child = getSingleTextChild(element) + if (child?.type === 'JSXText') { + const value = child.value.trim().replace(/\s+/gu, ' ') + return value ? `title=${JSON.stringify(value)}` : null + } + let hasUnsafeTitleExpression = false + if (child?.type === 'JSXExpressionContainer') { + if (isSafeToDuplicateTitleExpression(child.expression)) + return `title={${sourceCode.getText(child.expression)}}` + hasUnsafeTitleExpression = true + } + + for (const attributeName of ['value', 'placeholder', 'content', 'label', 'name', 'aria-label']) { + const attributeFixText = getTitleFixTextFromAttribute( + getAttribute(openingElement, attributeName), + sourceCode, + ) + if (attributeFixText === UNSAFE_TITLE_EXPRESSION) { + hasUnsafeTitleExpression = true + continue + } + if (attributeFixText) return attributeFixText + } + + return hasUnsafeTitleExpression ? UNSAFE_TITLE_EXPRESSION : null +} + +function hasTitledSingleChild(openingElement) { + const element = openingElement.parent + if (!element || element.type !== 'JSXElement') return false + const meaningfulChildren = getMeaningfulChildren(element) + if (meaningfulChildren.length !== 1 || meaningfulChildren[0].type !== 'JSXElement') return null + const titleAttribute = getAttribute(meaningfulChildren[0].openingElement, 'title') + return !!titleAttribute && !isEmptyTitleAttribute(titleAttribute) +} + +function isTooltipTriggerOpening(openingElement, tooltipTriggerNames) { + const name = getJsxName(openingElement.name) + return name !== null && tooltipTriggerNames.has(name) +} + +function isInsideTooltipTrigger(openingElement, tooltipTriggerNames) { + let current = openingElement.parent + while (current) { + if ( + current.type === 'JSXElement' && + isTooltipTriggerOpening(current.openingElement, tooltipTriggerNames) + ) + return true + if ( + current.type === 'JSXAttribute' && + current.name.type === 'JSXIdentifier' && + current.name.name === 'render' && + current.parent?.type === 'JSXOpeningElement' && + isTooltipTriggerOpening(current.parent, tooltipTriggerNames) + ) + return true + current = current.parent + } + return false +} + +function getOwningVariableName(openingElement) { + let current = openingElement.parent + while (current) { + if (current.type === 'VariableDeclarator') + return current.id.type === 'Identifier' ? current.id.name : null + if ( + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' || + current.type === 'ArrowFunctionExpression' + ) + return null + current = current.parent + } + return null +} + +function collectReferencedIdentifiers(node, names) { + const current = unwrapExpression(node) + if (!current) return + if (current.type === 'Identifier') { + names.add(current.name) + return + } + if (current.type === 'ConditionalExpression') { + collectReferencedIdentifiers(current.consequent, names) + collectReferencedIdentifiers(current.alternate, names) + } else if (current.type === 'LogicalExpression') { + collectReferencedIdentifiers(current.left, names) + collectReferencedIdentifiers(current.right, names) + } +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'suggestion', + docs: { + description: 'Flag truncated JSX text for manual full-content disclosure review', + }, + messages: { + emptyTitle: + 'Review this truncated text against the disclosure policy; do not replace its existing title automatically.', + missingTitle: + 'Review this truncated text against the AUTO, COVERED, SKIP, and REVIEW disclosure policy.', + }, + schema: [], + }, + create(context) { + const candidates = [] + const renderIdentifiers = new Set() + const tooltipTriggerNames = new Set() + const cssModuleBindings = new Map() + + function recordCssModuleImport(node) { + if (typeof node.source.value !== 'string' || !node.source.value.endsWith('.module.css')) + return + const defaultSpecifier = node.specifiers.find( + (specifier) => specifier.type === 'ImportDefaultSpecifier', + ) + if (!defaultSpecifier) return + + const filename = context.filename || context.getFilename() + if (!filename || filename.startsWith('<')) return + const cssModulePath = resolve(dirname(filename), node.source.value) + const classNames = getCssModuleClassNames(cssModulePath) + const variable = context.sourceCode.getDeclaredVariables(defaultSpecifier)[0] + if (classNames.size > 0 && variable) cssModuleBindings.set(variable, classNames) + } + + return { + ImportDeclaration(node) { + recordCssModuleImport(node) + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ImportSpecifier' && + (specifier.imported.name ?? specifier.imported.value) === 'TooltipTrigger' + ) + tooltipTriggerNames.add(specifier.local.name) + } + }, + JSXOpeningElement(node) { + if (isTooltipTriggerOpening(node, tooltipTriggerNames)) { + const renderAttribute = getAttribute(node, 'render') + if (renderAttribute?.value?.type === 'JSXExpressionContainer') + collectReferencedIdentifiers(renderAttribute.value.expression, renderIdentifiers) + } + + if ( + getAttribute(node, 'className') || + getAttribute(node, 'class') || + getAttribute(node, 'style') + ) + candidates.push(node) + }, + 'Program:exit': function () { + for (const openingElement of candidates) { + if (!hasTruncation(openingElement, context.sourceCode, cssModuleBindings)) continue + if (isInsideTooltipTrigger(openingElement, tooltipTriggerNames)) continue + + const owningVariableName = getOwningVariableName(openingElement) + if (owningVariableName && renderIdentifiers.has(owningVariableName)) continue + + if (hasMultipleTextChildren(openingElement)) continue + if (hasTitledSingleChild(openingElement)) continue + + const titleAttribute = getAttribute(openingElement, 'title') + if (titleAttribute && !isEmptyTitleAttribute(titleAttribute)) continue + + const fixText = getTitleFixText(openingElement, context.sourceCode) + if (fixText === UNSAFE_TITLE_EXPRESSION) continue + + context.report({ + node: titleAttribute ?? openingElement.name, + messageId: titleAttribute ? 'emptyTitle' : 'missingTitle', + }) + } + }, + } + }, +} diff --git a/web/plugins/eslint/rules/require-title-for-truncated-text.test.js b/web/plugins/eslint/rules/require-title-for-truncated-text.test.js new file mode 100644 index 00000000000..80fb1cbb62e --- /dev/null +++ b/web/plugins/eslint/rules/require-title-for-truncated-text.test.js @@ -0,0 +1,316 @@ +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 = () => <> + {name} + {name} + + `) + + 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 = () => <> + {name} + {name}} /> + + `) + + 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 }) => <> + {itemName} +

{label}

+
Description
+ + ` + 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 }) => <> + {label} + {label} + + ` + 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 <> + {label} + {label} + + } + `) + + 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 }) => {name} + ` + 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 }) => ( + {name} + ) + ` + 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 <> + {item.name} + {getLabel()} + {index++} + + + } + ` + 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 }) => <> +
{name}
+ + + `) + + 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 }) => <> + {primary} + {primary}{secondary} +
+ {primary} + {secondary} +
+ + `, + 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 }) => ( + + {primary} + {showOverlay && } + + ) + `) + + 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 }) => <> + {name} + {name} + {name} + {name} + {name} + {name} + + export const Shadowed = ({ styles, name }) => ( + {name} + ) + `, + 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 }) => <> + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + {name} + + ` + 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 }) => <> + {name} + {name} + {name} + {name} + {name} + + ` + 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 }) => <> + {42} + {\`Dify\`} + {name} +
{name}
+ + `) + + 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 = {name} + const fallbackTrigger = {name} + return + } + `) + + assert.equal(result.fixed, false) + assert.equal(result.messages.length, 0) +})