Fix step definition popup for mobile and escaped quotes
- Replace hover-based Tooltip with click-based popover for mobile support - Fix pattern extraction regex to handle escaped apostrophes (e.g., l'écran) - Add dashed underline (1.3px) to indicate clickable steps with definitions - Enable definitions mode by default - Regenerate stepDefinitions.ts with correct patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -115,7 +115,6 @@ export function FeatureView({ feature, onBack, onSelectScreen, onSelectStory }:
|
||||
<GherkinHighlighter
|
||||
content={feature.rawContent}
|
||||
scenarioResults={scenarioResults}
|
||||
filePath={feature.filePath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Code2 } from 'lucide-react';
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Code2, CheckCircle2, XCircle, AlertCircle, Clock, Table2 } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../ui/tooltip';
|
||||
import { Card, CardContent, CardHeader } from '../ui/card';
|
||||
import { findStepDefinition, type StepDefinitionInfo } from '../../data/stepDefinitions';
|
||||
|
||||
interface ScenarioResult {
|
||||
@@ -18,7 +13,6 @@ interface ScenarioResult {
|
||||
interface GherkinHighlighterProps {
|
||||
content: string;
|
||||
scenarioResults?: ScenarioResult[];
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
interface ParsedBlock {
|
||||
@@ -41,25 +35,28 @@ const keywords = {
|
||||
examples: ['Exemples:', 'Examples:'],
|
||||
};
|
||||
|
||||
export function GherkinHighlighter({ content, scenarioResults = [], filePath }: GherkinHighlighterProps) {
|
||||
export function GherkinHighlighter({ content, scenarioResults = [] }: GherkinHighlighterProps) {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Parse content into blocks
|
||||
const blocks = useMemo(() => parseBlocks(lines, scenarioResults), [lines, scenarioResults]);
|
||||
|
||||
// Determine initial collapsed state - collapsed by default, open if failed
|
||||
// Determine initial collapsed state - scenarios collapsed by default (open if failed), background always open
|
||||
const initialCollapsed = useMemo(() => {
|
||||
const state: Record<number, boolean> = {};
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'scenario' || block.type === 'background') {
|
||||
if (block.type === 'scenario') {
|
||||
state[index] = block.status !== 'failed';
|
||||
} else if (block.type === 'background') {
|
||||
// Background is always expanded
|
||||
state[index] = false;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}, [blocks]);
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>(initialCollapsed);
|
||||
const [showDefinitions, setShowDefinitions] = useState(false);
|
||||
const [showDefinitions, setShowDefinitions] = useState(true);
|
||||
|
||||
const toggleBlock = (index: number) => {
|
||||
setCollapsed(prev => ({ ...prev, [index]: !prev[index] }));
|
||||
@@ -76,76 +73,65 @@ export function GherkinHighlighter({ content, scenarioResults = [], filePath }:
|
||||
const collapseAll = () => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'scenario' || block.type === 'background') {
|
||||
if (block.type === 'scenario') {
|
||||
newState[index] = true;
|
||||
}
|
||||
// Background stays expanded
|
||||
});
|
||||
setCollapsed(newState);
|
||||
};
|
||||
|
||||
const collapsibleCount = blocks.filter(b => b.type === 'scenario' || b.type === 'background').length;
|
||||
const collapsedCount = Object.values(collapsed).filter(Boolean).length;
|
||||
const allCollapsed = collapsedCount === collapsibleCount;
|
||||
const scenarioCount = blocks.filter(b => b.type === 'scenario').length;
|
||||
const collapsedScenarioCount = blocks.filter((b, i) => b.type === 'scenario' && collapsed[i]).length;
|
||||
const allCollapsed = collapsedScenarioCount === scenarioCount;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="rounded-lg border border-border bg-zinc-950 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={allCollapsed ? expandAll : collapseAll}
|
||||
className="h-7 px-2 text-xs cursor-pointer text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
{allCollapsed ? (
|
||||
<>
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 mr-1" />
|
||||
Tout déplier
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsDownUp className="w-3.5 h-3.5 mr-1" />
|
||||
Tout replier
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showDefinitions ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowDefinitions(!showDefinitions)}
|
||||
className="h-7 px-2 text-xs cursor-pointer text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5 mr-1" />
|
||||
Définitions
|
||||
</Button>
|
||||
</div>
|
||||
{filePath && (
|
||||
<code className="text-xs text-zinc-500 truncate max-w-md">
|
||||
{filePath}
|
||||
</code>
|
||||
<div className="space-y-2" style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={allCollapsed ? expandAll : collapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{allCollapsed ? (
|
||||
<>
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Tout déplier</span>
|
||||
<span className="sm:hidden">Déplier</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsDownUp className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Tout replier</span>
|
||||
<span className="sm:hidden">Replier</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="font-mono text-sm leading-relaxed text-zinc-300">
|
||||
<code>
|
||||
{blocks.map((block, blockIndex) => (
|
||||
<BlockRenderer
|
||||
key={blockIndex}
|
||||
block={block}
|
||||
isCollapsed={collapsed[blockIndex] ?? false}
|
||||
onToggle={() => toggleBlock(blockIndex)}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={showDefinitions ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowDefinitions(!showDefinitions)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Définitions</span>
|
||||
<span className="sm:hidden">Déf.</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Scenario/Background Blocks */}
|
||||
{blocks.filter(b => b.type !== 'header').map((block, blockIndex) => (
|
||||
<BlockRenderer
|
||||
key={blockIndex}
|
||||
block={block}
|
||||
isCollapsed={collapsed[blocks.indexOf(block)] ?? false}
|
||||
onToggle={() => toggleBlock(blocks.indexOf(block))}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,89 +206,371 @@ interface BlockRendererProps {
|
||||
|
||||
function BlockRenderer({ block, isCollapsed, onToggle, showDefinitions }: BlockRendererProps) {
|
||||
if (block.type === 'header') {
|
||||
return (
|
||||
<>
|
||||
{block.lines.map((line, index) => (
|
||||
<LineRenderer
|
||||
key={block.startLine + index}
|
||||
line={line}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
// Header is now handled separately in the main component
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = block.lines[0] ?? '';
|
||||
const restLines = block.lines.slice(1);
|
||||
const isCollapsible = block.type === 'scenario' || block.type === 'background';
|
||||
const isBackground = block.type === 'background';
|
||||
|
||||
// Parse steps from rest lines
|
||||
const parsedSteps = parseStepsFromLines(restLines);
|
||||
|
||||
// Determine border color based on status
|
||||
const borderColor = block.status === 'passed' ? 'border-l-green-500' :
|
||||
block.status === 'failed' ? 'border-l-red-500' :
|
||||
block.status === 'skipped' ? 'border-l-yellow-500' :
|
||||
isBackground ? 'border-l-zinc-400' : 'border-l-cyan-500';
|
||||
|
||||
// Status icon
|
||||
const StatusIcon = () => {
|
||||
if (!block.status || block.status === 'unknown') return null;
|
||||
if (block.status === 'passed') return <CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
if (block.status === 'failed') return <XCircle className="w-4 h-4 text-red-500 shrink-0" />;
|
||||
if (block.status === 'skipped') return <AlertCircle className="w-4 h-4 text-yellow-500 shrink-0" />;
|
||||
return <Clock className="w-4 h-4 text-zinc-400 shrink-0" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Scenario/Background header line */}
|
||||
<div
|
||||
className={`flex hover:bg-zinc-800/50 ${isCollapsible ? 'cursor-pointer' : ''}`}
|
||||
onClick={isCollapsible ? onToggle : undefined}
|
||||
<Card className={`border-l-4 ${borderColor}`}>
|
||||
{/* Clickable header */}
|
||||
<CardHeader
|
||||
className="p-2 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="flex items-center gap-1 flex-1">
|
||||
{isCollapsible && (
|
||||
<span className="w-4 h-4 flex items-center justify-center text-zinc-500">
|
||||
{isCollapsed ? <ChevronRight className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</span>
|
||||
<StatusIcon />
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded shrink-0 ${
|
||||
isBackground
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400'
|
||||
: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400'
|
||||
}`}>
|
||||
{isBackground ? 'Contexte' : 'Scénario'}
|
||||
</span>
|
||||
<span className="font-medium text-foreground text-sm truncate sm:whitespace-normal">
|
||||
{block.name}
|
||||
</span>
|
||||
</div>
|
||||
{parsedSteps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">
|
||||
{parsedSteps.length} étapes
|
||||
</span>
|
||||
)}
|
||||
{block.status && block.status !== 'unknown' && (
|
||||
<span className={`w-2 h-2 rounded-full mr-1 ${
|
||||
block.status === 'passed' ? 'bg-green-500' :
|
||||
block.status === 'failed' ? 'bg-red-500' :
|
||||
block.status === 'skipped' ? 'bg-yellow-500' :
|
||||
'bg-zinc-600'
|
||||
}`} />
|
||||
)}
|
||||
{highlightLine(firstLine, false)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Collapsible content */}
|
||||
{!isCollapsed && restLines.map((line, index) => (
|
||||
<LineRenderer
|
||||
key={block.startLine + index + 1}
|
||||
line={line}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
{!isCollapsed && (
|
||||
<CardContent className="pt-0 px-2 pb-2">
|
||||
<div className="space-y-0.5 ml-0 sm:ml-6">
|
||||
{parsedSteps.map((step, index) => (
|
||||
<StepRenderer
|
||||
key={index}
|
||||
step={step}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error message for failed scenarios */}
|
||||
{!isCollapsed && block.status === 'failed' && block.errorMessage && (
|
||||
<div className="ml-5 my-2 p-3 bg-red-950 border border-red-800 rounded-md">
|
||||
<div className="text-xs font-medium text-red-400 mb-1">Erreur:</div>
|
||||
<pre className="text-xs text-red-300 whitespace-pre-wrap break-words font-mono">
|
||||
{block.errorMessage}
|
||||
</pre>
|
||||
</div>
|
||||
{/* Error message for failed scenarios */}
|
||||
{block.status === 'failed' && block.errorMessage && (
|
||||
<div className="ml-0 sm:ml-6 mt-2 p-2 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Erreur:</div>
|
||||
<pre className="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap break-words font-mono overflow-x-auto">
|
||||
{block.errorMessage}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LineRendererProps {
|
||||
line: string;
|
||||
interface ParsedStep {
|
||||
type: 'given' | 'when' | 'then' | 'and' | 'examples' | 'table' | 'other';
|
||||
keyword: string;
|
||||
text: string;
|
||||
originalLine: string;
|
||||
tableRows?: string[][];
|
||||
}
|
||||
|
||||
function parseStepsFromLines(lines: string[]): ParsedStep[] {
|
||||
const steps: ParsedStep[] = [];
|
||||
let currentStep: ParsedStep | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for table row
|
||||
if (trimmed.startsWith('|')) {
|
||||
const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
|
||||
if (currentStep) {
|
||||
if (!currentStep.tableRows) currentStep.tableRows = [];
|
||||
currentStep.tableRows.push(cells);
|
||||
} else {
|
||||
// Standalone table row (shouldn't happen, but handle it)
|
||||
steps.push({
|
||||
type: 'table',
|
||||
keyword: '',
|
||||
text: trimmed,
|
||||
originalLine: line,
|
||||
tableRows: [cells]
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for step keywords
|
||||
let matched = false;
|
||||
|
||||
for (const kw of keywords.given) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'given', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.when) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'when', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.then) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'then', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.and) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'and', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.examples) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'examples', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched && trimmed) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'other', keyword: '', text: trimmed, originalLine: line };
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep) steps.push(currentStep);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
interface StepRendererProps {
|
||||
step: ParsedStep;
|
||||
showDefinitions: boolean;
|
||||
}
|
||||
|
||||
function LineRenderer({ line, showDefinitions }: LineRendererProps) {
|
||||
const trimmed = line.trim();
|
||||
const isStep = [...keywords.given, ...keywords.when, ...keywords.then, ...keywords.and]
|
||||
.some(kw => trimmed.startsWith(kw));
|
||||
function StepRenderer({ step, showDefinitions }: StepRendererProps) {
|
||||
// Always check for step definition to show dotted underline
|
||||
const stepDef = step.type !== 'table' && step.type !== 'other' && step.type !== 'examples'
|
||||
? findStepDefinition(step.originalLine.trim())
|
||||
: null;
|
||||
|
||||
const stepDef = isStep && showDefinitions ? findStepDefinition(trimmed) : null;
|
||||
// Keyword colors
|
||||
const keywordColor = step.type === 'given' ? 'text-blue-600 dark:text-blue-400' :
|
||||
step.type === 'when' ? 'text-amber-600 dark:text-amber-400' :
|
||||
step.type === 'then' ? 'text-green-600 dark:text-green-400' :
|
||||
step.type === 'and' ? 'text-zinc-500 dark:text-zinc-400' :
|
||||
step.type === 'examples' ? 'text-purple-600 dark:text-purple-400' :
|
||||
'text-muted-foreground';
|
||||
|
||||
const keywordBg = step.type === 'given' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
||||
step.type === 'when' ? 'bg-amber-50 dark:bg-amber-950/30' :
|
||||
step.type === 'then' ? 'bg-green-50 dark:bg-green-950/30' :
|
||||
step.type === 'and' ? 'bg-zinc-50 dark:bg-zinc-800/50' :
|
||||
step.type === 'examples' ? 'bg-purple-50 dark:bg-purple-950/30' :
|
||||
'';
|
||||
|
||||
if (step.type === 'table') {
|
||||
return (
|
||||
<div className="ml-2 sm:ml-4 my-2">
|
||||
<Table2 className="w-4 h-4 text-muted-foreground inline mr-2" />
|
||||
<span className="text-sm text-muted-foreground">{step.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show popover only when definitions mode is active, but always show dotted underline for steps with definitions
|
||||
const dottedUnderlineStyle = {
|
||||
borderBottom: '1.3px dashed',
|
||||
borderColor: 'rgb(161 161 170)', // zinc-400
|
||||
};
|
||||
const stepTextElement = stepDef ? (
|
||||
showDefinitions ? (
|
||||
<StepDefinitionPopover stepDef={stepDef}>
|
||||
{highlightStringsInText(step.text)}
|
||||
</StepDefinitionPopover>
|
||||
) : (
|
||||
<span style={dottedUnderlineStyle}>
|
||||
{highlightStringsInText(step.text)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>{highlightStringsInText(step.text)}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="hover:bg-zinc-800/50">
|
||||
{highlightLineWithTooltip(line, stepDef)}
|
||||
<div className="py-0.5">
|
||||
<div className={`flex items-start gap-1.5 px-1.5 py-0.5 rounded ${keywordBg}`}>
|
||||
{step.keyword && (
|
||||
<span className={`font-medium text-sm shrink-0 ${keywordColor}`}>
|
||||
{step.keyword}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-foreground break-words">
|
||||
{stepTextElement}
|
||||
</span>
|
||||
</div>
|
||||
{/* Render table if present */}
|
||||
{step.tableRows && step.tableRows.length > 0 && (
|
||||
<div className="ml-0 sm:ml-4 mt-1 overflow-x-auto -mx-1 px-1">
|
||||
<table className="text-sm border-collapse min-w-full">
|
||||
<tbody>
|
||||
{step.tableRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className={rowIndex === 0 ? 'font-medium' : ''}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className="px-2 py-1 border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightStringsInText(text: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /"[^"]*"/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(
|
||||
<span key={`string-${match.index}`} className="font-medium text-orange-600 dark:text-orange-400">
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}
|
||||
|
||||
// Click-based popover for step definitions (works on mobile and desktop)
|
||||
function StepDefinitionPopover({
|
||||
stepDef,
|
||||
children
|
||||
}: {
|
||||
stepDef: StepDefinitionInfo;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close on escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const dottedUnderlineStyle = {
|
||||
borderBottom: '1.3px dashed rgb(161 161 170)', // zinc-400
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline">
|
||||
<span
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="cursor-pointer"
|
||||
style={dottedUnderlineStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute left-0 top-full mt-1 z-50 shadow-xl rounded-lg"
|
||||
style={{ minWidth: '300px', maxWidth: 'min(90vw, 500px)' }}
|
||||
>
|
||||
<SourceCodePopup stepDef={stepDef} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCodePopup({ stepDef }: { stepDef: StepDefinitionInfo }) {
|
||||
const lines = stepDef.sourceCode.split('\n');
|
||||
|
||||
@@ -402,172 +670,3 @@ function highlightTypeScript(code: string): React.ReactNode {
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
function highlightLine(line: string, hasDefinition: boolean): React.ReactNode {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for tags
|
||||
if (trimmed.startsWith('@')) {
|
||||
return <span className="text-zinc-500">{line}</span>;
|
||||
}
|
||||
|
||||
// Check for comments
|
||||
if (trimmed.startsWith('#') && !trimmed.includes('language:')) {
|
||||
return <span className="text-zinc-600 italic">{line}</span>;
|
||||
}
|
||||
|
||||
// Check for language declaration
|
||||
if (trimmed.includes('# language:')) {
|
||||
return <span className="text-zinc-600">{line}</span>;
|
||||
}
|
||||
|
||||
// Check for feature keywords
|
||||
for (const kw of keywords.feature) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
return highlightKeyword(line, kw, 'text-purple-400 font-semibold', hasDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for background
|
||||
for (const kw of keywords.background) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
return highlightKeyword(line, kw, 'text-zinc-400 font-medium', hasDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for scenario keywords
|
||||
for (const kw of keywords.scenario) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
return highlightKeyword(line, kw, 'text-cyan-400 font-medium', hasDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for step keywords
|
||||
for (const kw of [...keywords.given, ...keywords.when, ...keywords.then, ...keywords.and]) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
const color = keywords.given.includes(kw) ? 'text-blue-400' :
|
||||
keywords.when.includes(kw) ? 'text-amber-400' :
|
||||
keywords.then.includes(kw) ? 'text-green-400' :
|
||||
'text-zinc-400';
|
||||
return highlightKeyword(line, kw, color, hasDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for examples
|
||||
for (const kw of keywords.examples) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
return highlightKeyword(line, kw, 'text-zinc-400 font-medium', hasDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for table rows
|
||||
if (trimmed.startsWith('|')) {
|
||||
return <span className="text-zinc-500">{line}</span>;
|
||||
}
|
||||
|
||||
return <span className="text-zinc-400">{line}</span>;
|
||||
}
|
||||
|
||||
function highlightLineWithTooltip(line: string, stepDef: StepDefinitionInfo | null): React.ReactNode {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Find which step keyword this line starts with
|
||||
const allStepKeywords = [...keywords.given, ...keywords.when, ...keywords.then, ...keywords.and];
|
||||
let matchedKeyword: string | null = null;
|
||||
for (const kw of allStepKeywords) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
matchedKeyword = kw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no step keyword or no definition, use regular highlighting
|
||||
if (!matchedKeyword || !stepDef) {
|
||||
return highlightLine(line, false);
|
||||
}
|
||||
|
||||
// Split the line: indentation + keyword + step text
|
||||
const index = line.indexOf(matchedKeyword);
|
||||
const before = line.slice(0, index);
|
||||
const after = line.slice(index + matchedKeyword.length);
|
||||
|
||||
// Determine keyword color
|
||||
const color = keywords.given.includes(matchedKeyword) ? 'text-blue-400' :
|
||||
keywords.when.includes(matchedKeyword) ? 'text-amber-400' :
|
||||
keywords.then.includes(matchedKeyword) ? 'text-green-400' :
|
||||
'text-zinc-400';
|
||||
|
||||
// Separate leading space from the step text
|
||||
const leadingSpaceMatch = after.match(/^(\s*)/);
|
||||
const leadingSpace = leadingSpaceMatch?.[1] ?? '';
|
||||
const stepText = after.slice(leadingSpace.length);
|
||||
|
||||
// Wrap only the step text (after keyword and space) with tooltip
|
||||
const stepTextElement = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="underline decoration-dotted decoration-zinc-600 cursor-help">
|
||||
{highlightStrings(stepText)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="p-0 max-w-lg border-0 shadow-xl"
|
||||
>
|
||||
<SourceCodePopup stepDef={stepDef} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span>{before}</span>
|
||||
<span className={color}>{matchedKeyword}</span>
|
||||
<span>{leadingSpace}</span>
|
||||
{stepTextElement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightKeyword(line: string, keyword: string, className: string, hasDefinition: boolean): React.ReactNode {
|
||||
const index = line.indexOf(keyword);
|
||||
const before = line.slice(0, index);
|
||||
const after = line.slice(index + keyword.length);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span>{before}</span>
|
||||
<span className={className}>{keyword}</span>
|
||||
<span className={hasDefinition ? 'underline decoration-dotted decoration-zinc-600' : ''}>
|
||||
{highlightStrings(after)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightStrings(text: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /"[^"]*"/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(
|
||||
<span key={`string-${match.index}`} className="text-orange-300">
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}
|
||||
|
||||
+24
-17
@@ -18,7 +18,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 31
|
||||
},
|
||||
{
|
||||
"pattern": "je suis connecté en tant qu\\",
|
||||
"pattern": "je suis connecté en tant qu'utilisateur",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis connecté en tant qu\\'utilisateur', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
|
||||
@@ -88,7 +88,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 73
|
||||
},
|
||||
{
|
||||
"pattern": "je vois l\\",
|
||||
"pattern": "je vois l'écran {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je vois l\\'écran {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
|
||||
@@ -102,28 +102,35 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 83
|
||||
},
|
||||
{
|
||||
"pattern": "l\\",
|
||||
"pattern": "l'écran contient une section {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran contient une section {string}', async function (this: FestipodWorld, sectionName: string) {\n expect(this.currentScreenId).to.not.be.null;\n this.attach(`Verified section: ${sectionName}`, 'text/plain');\n});",
|
||||
"sourceCode": "Then('l\\'écran contient une section {string}', async function (this: FestipodWorld, sectionName: string) {\n const hasSection = this.hasText(sectionName);\n expect(hasSection, `Section \"${sectionName}\" should be visible on screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 88
|
||||
},
|
||||
{
|
||||
"pattern": "je peux annuler et revenir à l'écran précédent",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je peux annuler et revenir à l\\'écran précédent', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n // CreateEventScreen has a ✕ close button in the header with onClick={() => navigate('home')}\n const source = this.getRenderedText();\n const hasCloseButton = /onClick[^>]*>[^<]*✕/.test(source);\n expect(hasCloseButton, 'Create event screen should have a close button (✕) with navigation action').to.be.true;\n});",
|
||||
"lineNumber": 93
|
||||
},
|
||||
{
|
||||
"pattern": "je peux naviguer vers {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je peux naviguer vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n this.attach(`Navigation available to: ${screenId}`, 'text/plain');\n});",
|
||||
"lineNumber": 93
|
||||
"lineNumber": 101
|
||||
},
|
||||
{
|
||||
"pattern": "la navigation affiche {string} comme actif",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('la navigation affiche {string} comme actif', async function (this: FestipodWorld, menuItem: string) {\n this.attach(`Active menu: ${menuItem}`, 'text/plain');\n});",
|
||||
"lineNumber": 98
|
||||
"lineNumber": 106
|
||||
},
|
||||
{
|
||||
"pattern": "l\\",
|
||||
"pattern": "l'écran {string} est affiché",
|
||||
"keyword": "Given",
|
||||
"file": "form.steps.ts",
|
||||
"sourceCode": "Given('l\\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {\n const screenId = screenName.toLowerCase().replace(/ /g, '-');\n this.navigateTo(`#/demo/${screenId}`);\n});",
|
||||
@@ -214,7 +221,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 6
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir les détails de l\\",
|
||||
"pattern": "je peux voir les détails de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux voir les détails de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n // Verify event detail content is rendered\n const hasEventInfo = this.hasText('Description') || this.hasText('Participant') || this.hasText('inscrits');\n expect(hasEventInfo, 'Event detail page should show event information').to.be.true;\n});",
|
||||
@@ -242,7 +249,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 36
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir le profil de l\\",
|
||||
"pattern": "je peux voir le profil de l'utilisateur",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux voir le profil de l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const hasProfileContent = this.hasText('Profil') || this.hasText('@');\n expect(hasProfileContent, 'User profile should display profile information').to.be.true;\n});",
|
||||
@@ -284,7 +291,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 74
|
||||
},
|
||||
{
|
||||
"pattern": "je visualise l\\",
|
||||
"pattern": "je visualise l'événement {string}",
|
||||
"keyword": "Given",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Given('je visualise l\\'événement {string}', async function (this: FestipodWorld, eventName: string) {\n this.navigateTo('#/demo/event-detail');\n expect(this.currentScreen, 'Event detail screen should be loaded').to.not.be.null;\n this.attach(`Viewing event: ${eventName}`, 'text/plain');\n});",
|
||||
@@ -298,14 +305,14 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 85
|
||||
},
|
||||
{
|
||||
"pattern": "l\\",
|
||||
"pattern": "l'écran affiche les informations de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran affiche les informations de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n // Verify actual content is rendered\n const expectedContent = screenExpectedContent['event-detail'] || [];\n const renderedText = this.getRenderedText();\n\n let foundCount = 0;\n for (const content of expectedContent) {\n if (renderedText.includes(content)) {\n foundCount++;\n }\n }\n\n expect(foundCount, `At least one expected content item should be present`).to.be.greaterThan(0);\n});",
|
||||
"lineNumber": 91
|
||||
},
|
||||
{
|
||||
"pattern": "l\\",
|
||||
"pattern": "l'écran affiche les informations du profil",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran affiche les informations du profil', async function (this: FestipodWorld) {\n expect(['profile', 'user-profile']).to.include(this.currentScreenId);\n // Verify profile info is rendered\n const hasProfileInfo = this.hasText('Profil') || this.hasText('@') || this.hasText('Événement');\n expect(hasProfileInfo, 'Profile information should be displayed').to.be.true;\n});",
|
||||
@@ -347,28 +354,28 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 152
|
||||
},
|
||||
{
|
||||
"pattern": "je peux m\\",
|
||||
"pattern": "je peux m'inscrire à l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n // Check for registration button\n const hasRegisterFeature = this.hasText('inscription') || this.hasText('Participer') ||\n this.hasText('participer') || this.hasText('S\\'inscrire') ||\n this.hasText('Rejoindre');\n expect(hasRegisterFeature, 'Registration feature should be available').to.be.true;\n});",
|
||||
"lineNumber": 158
|
||||
},
|
||||
{
|
||||
"pattern": "je peux me désinscrire de l\\",
|
||||
"pattern": "je peux me désinscrire de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n // Unregister is typically on the same page as register\n const hasUnregisterFeature = this.hasText('désinscri') || this.hasText('Annuler') ||\n this.hasText('Quitter') || this.hasElement('button');\n expect(hasUnregisterFeature, 'Unregister feature should be available').to.be.true;\n});",
|
||||
"lineNumber": 167
|
||||
},
|
||||
{
|
||||
"pattern": "je peux contacter l\\",
|
||||
"pattern": "je peux contacter l'utilisateur",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux contacter l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n // Check for contact functionality\n const hasContactFeature = this.hasText('Contact') || this.hasText('Message') ||\n this.hasText('message') || this.hasElement('button');\n expect(hasContactFeature, 'Contact feature should be available').to.be.true;\n});",
|
||||
"lineNumber": 175
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir les événements auxquels l\\",
|
||||
"pattern": "je peux voir les événements auxquels l'utilisateur a participé",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux voir les événements auxquels l\\'utilisateur a participé', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n // Check for user's events\n const hasUserEvents = this.hasText('Événement') || this.hasText('événement') ||\n this.hasText('Participation') || this.hasText('participation');\n expect(hasUserEvents, 'User events should be visible').to.be.true;\n});",
|
||||
@@ -389,7 +396,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"lineNumber": 198
|
||||
},
|
||||
{
|
||||
"pattern": "je peux définir mes thématiques d\\",
|
||||
"pattern": "je peux définir mes thématiques d'intérêt",
|
||||
"keyword": "Then",
|
||||
"file": "screen.steps.ts",
|
||||
"sourceCode": "Then('je peux définir mes thématiques d\\'intérêt', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('settings');\n // Settings page should allow configuring interests (or it could be on profile)\n // For now just verify we're on settings\n expect(this.currentScreen, 'Settings screen should be loaded').to.not.be.null;\n});",
|
||||
|
||||
Reference in New Issue
Block a user