diff --git a/frontend/docs/pipelines.md b/frontend/docs/pipelines.md index 8b8020a9334..40fc4e941bb 100644 --- a/frontend/docs/pipelines.md +++ b/frontend/docs/pipelines.md @@ -5,6 +5,13 @@ - Manage Kubeflow Pipelines (KFP) v2 workflows in a Data Science Project: import definitions, create and schedule runs, inspect DAGs and outputs, compare metrics, and browse MLMD artifacts and executions. - Namespace-scoped; requires a ready pipeline server (`DSPipelineKind` CR) before operations are available. +## Keyboard interaction with pipeline graphs + +- Tab through task, artifact, and task-group controls in visual reading order. Screen readers announce each control's name and available status. +- Press Enter or Space on a task or artifact to open its details. Closing the details returns focus to the graph control. +- For a collapsed task group, use its task-list button to choose a child or view group details. Use the task-group toggle to expand or collapse the group. +- Focusing a graph control pans it into the visible graph viewport when needed. + ## UI Entry Points | Nav Item | Route | Feature Flag(s) Required | diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx index aa545875ff8..70500bc8754 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx @@ -89,7 +89,7 @@ const PipelineDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) = const panelContent = selectedNode ? ( setSelectedIds(undefined)} + onClose={() => setSelectedIds([])} /> ) : null; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/SelectedTaskDrawerContent.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/SelectedTaskDrawerContent.tsx index 60304a685c5..5d3128c4d7b 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/SelectedTaskDrawerContent.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/SelectedTaskDrawerContent.tsx @@ -22,7 +22,7 @@ const SelectedTaskDrawerContent: React.FC = ({ t return ( <> - + <Title headingLevel="h2" size="xl" tabIndex={-1} data-testid="pipeline-drawer-task-title"> {task.name} {task.type === 'artifact' ? 'Artifact details' : ''} diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx index eec2c1f0644..7caa0e1714d 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx @@ -83,7 +83,7 @@ const PipelineRecurringRunDetails: PipelineCoreDetailsPageComponent = ({ const panelContent = selectedNode ? ( setSelectedIds(undefined)} + onClose={() => setSelectedIds([])} /> ) : null; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx index abb46436e28..428a36a1acf 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx @@ -135,7 +135,7 @@ const PipelineRunDetails: React.FC< setSelectedIds(undefined)} + onClose={() => setSelectedIds([])} executions={drawerExecutions} run={run} /> diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightContent.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightContent.tsx index d65398f1f74..88b460d697b 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightContent.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightContent.tsx @@ -48,7 +48,12 @@ const PipelineRunDrawerRightContent: React.FC - + <Title + headingLevel="h2" + size="xl" + tabIndex={-1} + data-testid="pipeline-drawer-task-title" + > {task.name} {task.status?.podName && ( diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightTabs.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightTabs.tsx index 8bffe4b758b..1848f8ab0c0 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightTabs.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerRightTabs.tsx @@ -81,7 +81,12 @@ const PipelineRunDrawerRightTabs: React.FC = ({ return ( <> - + setSelection(String(tabKey))} + > {Object.entries(tabs).map(([tabName, { title, isDisabled }]) => ( = ({ eventKey={tabName} tabContentId={tabName} isDisabled={isDisabled} - onClick={() => setSelection(tabName)} /> ))} diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactNodeDrawerContent.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactNodeDrawerContent.tsx index 7763dfa55f5..6dc6c73c054 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactNodeDrawerContent.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/ArtifactNodeDrawerContent.tsx @@ -41,7 +41,7 @@ export const ArtifactNodeDrawerContent: React.FC return task ? ( <> - + <Title headingLevel="h2" size="xl" tabIndex={-1} data-testid="pipeline-drawer-task-title"> {task.name} {task.status?.podName && {task.status.podName}} diff --git a/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts b/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts index 50fca619795..6843f2c3085 100644 --- a/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts +++ b/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts @@ -14,6 +14,7 @@ jest.mock('@patternfly/react-topology', () => ({ Skipped: 'Skipped', }, observer: (c: unknown) => c, + action: (fn: (...args: unknown[]) => unknown) => fn, GraphComponent: {}, ModelKind: { graph: 'graph', node: 'node', edge: 'edge' }, ComponentFactory: {}, diff --git a/frontend/src/concepts/topology/NodeStatusIcon.tsx b/frontend/src/concepts/topology/NodeStatusIcon.tsx index 464b111f94c..afe01171f34 100644 --- a/frontend/src/concepts/topology/NodeStatusIcon.tsx +++ b/frontend/src/concepts/topology/NodeStatusIcon.tsx @@ -5,18 +5,31 @@ import { CheckCircleIcon, ExclamationCircleIcon, InProgressIcon, + OutlinedWindowRestoreIcon, PendingIcon, } from '@patternfly/react-icons'; import { RunStatus } from '@patternfly/react-topology'; import { Icon, Tooltip } from '@patternfly/react-core'; -import { runtimeStateLabels, RuntimeStateKF } from '#~/concepts/pipelines/kfTypes'; +import { + ExecutionStateKF, + runtimeStateLabels, + RuntimeStateKF, +} from '#~/concepts/pipelines/kfTypes'; -const NodeStatusIcon: React.FC<{ runStatus: RunStatus | string }> = ({ runStatus }) => { +const NodeStatusIcon: React.FC<{ + runStatus?: RunStatus | string; + executionState?: ExecutionStateKF | string; +}> = ({ runStatus, executionState }) => { let icon: React.ReactNode; let status: React.ComponentProps['status']; let label: string; - switch (runStatus) { + switch (executionState === ExecutionStateKF.CACHED ? ExecutionStateKF.CACHED : runStatus) { + case ExecutionStateKF.CACHED: + icon = ; + status = 'success'; + label = 'Cached'; + break; case RunStatus.Pending: case RunStatus.Idle: icon = ; @@ -54,9 +67,13 @@ const NodeStatusIcon: React.FC<{ runStatus: RunStatus | string }> = ({ runStatus label = ''; } + if (!icon || !label) { + return null; + } + return ( - + {icon} diff --git a/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx b/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx index e7414627e46..f8b41bc79c3 100644 --- a/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx +++ b/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx @@ -14,15 +14,32 @@ import { PipelineNodeModel, TaskGroupPillLabel, LabelPosition, + SELECTION_EVENT, + useCombineRefs, + useVisualizationController, } from '@patternfly/react-topology'; -import { Flex, FlexItem, Popover, Stack, StackItem } from '@patternfly/react-core'; +import { Button, Flex, FlexItem, Popover, Stack, StackItem } from '@patternfly/react-core'; import { BanIcon } from '@patternfly/react-icons'; import { PipelineNodeModelExpanded, StandardTaskNodeData } from '#~/concepts/topology/types'; import NodeStatusIcon from '#~/concepts/topology/NodeStatusIcon'; import { ExecutionStateKF } from '#~/concepts/pipelines/kfTypes'; +import { getPipelineTaskStatusLabel } from '#~/concepts/topology/utils'; +import { + hideSvgTaskLabelsFromKeyboard, + isHiddenByCollapsedAncestor, + panFocusedControlIntoView, +} from '#~/concepts/topology/a11yUtils'; +import { activateNodeButtonOnKeyDown } from '#~/concepts/topology/PipelineNodeButton'; import { NODE_HEIGHT, NODE_WIDTH } from './const'; const MAX_TIP_ITEMS = 6; +// Approximate PF pill-label action bounds until the measured SVG rectangles are available: +// 42.5-unit inset from the default right edge, 13-unit rise, and a 28.25 × 26-unit toggle. +const EXPANDED_TOGGLE_OFFSET_FROM_CENTER = NODE_WIDTH / 2 - 42.5; + +type ControlBounds = { x: number; y: number; width: number; height: number }; + +type ExpandedControlBounds = { details: ControlBounds; toggle: ControlBounds }; type PipelinesDefaultGroupProps = { element: GraphElement; @@ -32,94 +49,414 @@ type PipelinesDefaultGroupInnerProps = Omit; }; +type TaskGroupLabelContextValue = { + groupLabel: string; + badgeText?: string; + status?: RunStatus; + onActionChange: (handler: ((event: React.MouseEvent) => void) | undefined) => void; + onLabelLayoutChange: (label: SVGGElement) => void; +}; + +const TaskGroupLabelContext = React.createContext(null); + +const AccessibleTaskGroupPillLabel: React.FC> = ( + props, +) => { + const context = React.useContext(TaskGroupLabelContext); + if (!context) { + throw new Error('AccessibleTaskGroupPillLabel requires TaskGroupLabelContext'); + } + const { groupLabel, badgeText, status, onActionChange, onLabelLayoutChange } = context; + const labelRef = React.useRef(null); + + React.useLayoutEffect(() => { + onActionChange(props.onActionIconClick); + return () => { + onActionChange(undefined); + }; + }, [onActionChange, props.onActionIconClick]); + + React.useLayoutEffect(() => { + const label = labelRef.current; + if (!label) { + return undefined; + } + + const updateLabel = () => { + hideSvgTaskLabelsFromKeyboard(label); + onLabelLayoutChange(label); + }; + updateLabel(); + const labelObserver = new MutationObserver(updateLabel); + labelObserver.observe(label, { + attributes: true, + attributeFilter: ['transform', 'x', 'y', 'width', 'height'], + childList: true, + subtree: true, + }); + return () => labelObserver.disconnect(); + }, [onLabelLayoutChange]); + + return ( + + : undefined} + /> + + ); +}; + const DefaultTaskGroupInner: React.FunctionComponent = observer( ({ element, selected, onSelect }) => { + const controller = useVisualizationController(); const [hover, hoverRef] = useHover(); - const popoverRef = React.useRef(null); + const groupRef = React.useRef(null); + const combinedRef = useCombineRefs(hoverRef, groupRef); + const [popoverOpen, setPopoverOpen] = React.useState(false); + const [toggleFocused, setToggleFocused] = React.useState(false); + const [expandedControlBounds, setExpandedControlBounds] = + React.useState(null); + const popoverRef = React.useRef(null); + const toggleRef = React.useRef(null); + const expandedActionRef = React.useRef<((event: React.MouseEvent) => void) | undefined>(); + const setExpandedAction = React.useCallback( + (handler: ((event: React.MouseEvent) => void) | undefined) => { + expandedActionRef.current = handler; + }, + [], + ); + const updateExpandedControlBounds = React.useCallback((label: SVGGElement) => { + const group = groupRef.current; + const actionIcon = label.querySelector('.pf-topology__node__action-icon'); + const matrix = group?.getScreenCTM(); + if (!actionIcon || !matrix) { + return; + } + + const inverse = matrix.inverse(); + const labelRect = label.getBoundingClientRect(); + const actionRect = actionIcon.getBoundingClientRect(); + if (!labelRect.width || !actionRect.width) { + return; + } + const toLocal = (rect: DOMRect): ControlBounds => { + const start = new DOMPoint(rect.left, rect.top).matrixTransform(inverse); + const end = new DOMPoint(rect.right, rect.bottom).matrixTransform(inverse); + return { x: start.x, y: start.y, width: end.x - start.x, height: end.y - start.y }; + }; + const details = toLocal( + new DOMRect( + labelRect.left, + labelRect.top, + actionRect.left - labelRect.left, + labelRect.height, + ), + ); + const toggle = toLocal(actionRect); + setExpandedControlBounds((previous) => { + const next = { details, toggle }; + const sameBounds = (a: ControlBounds, b: ControlBounds) => + Math.abs(a.x - b.x) < 0.1 && + Math.abs(a.y - b.y) < 0.1 && + Math.abs(a.width - b.width) < 0.1 && + Math.abs(a.height - b.height) < 0.1; + return previous && + sameBounds(previous.details, details) && + sameBounds(previous.toggle, toggle) + ? previous + : next; + }); + }, []); + const focusToggleAfterCollapse = React.useRef(false); const detailsLevel = element.getGraph().getDetailsLevel(); + const isCollapsed = element.isCollapsed(); + const isHidden = isHiddenByCollapsedAncestor(element); const runStatus = element.getData()?.runStatus; const state = element.getData()?.pipelineTask.status?.state; - const getPopoverTasksList = (items: Node[]) => ( + React.useEffect(() => { + if (!isCollapsed || isHidden) { + setPopoverOpen(false); + } + }, [isCollapsed, isHidden]); + + React.useEffect(() => { + if (focusToggleAfterCollapse.current) { + toggleRef.current?.focus(); + focusToggleAfterCollapse.current = false; + } + }, [isCollapsed]); + + React.useLayoutEffect(() => { + const group = groupRef.current; + if (!group) { + return undefined; + } + + hideSvgTaskLabelsFromKeyboard(group); + const groupObserver = new MutationObserver(() => hideSvgTaskLabelsFromKeyboard(group)); + groupObserver.observe(group, { childList: true, subtree: true }); + return () => groupObserver.disconnect(); + }, []); + + const activateCollapseAction = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!isCollapsed && expandedActionRef.current) { + focusToggleAfterCollapse.current = true; + expandedActionRef.current(event); + return; + } + + // DefaultTaskGroup owns collapse state, layout, and viewport updates. It exposes no + // imperative expand action, so route this button through its collapsed SVG action icon. + // Keep the group-toggle Cypress test when updating PatternFly's action icon markup. + const actionIcon = groupRef.current?.querySelector( + '.pf-topology__node__action-icon', + ); + if (actionIcon) { + focusToggleAfterCollapse.current = true; + actionIcon.dispatchEvent(new MouseEvent('click', { bubbles: true })); + } + }, + [isCollapsed], + ); + + const selectNode = React.useCallback( + (nodeId: string) => { + controller.fireEvent(SELECTION_EVENT, [nodeId]); + }, + [controller], + ); + + const getPopoverTasksList = (items: Node[], hidePopover: () => void) => ( - {items.slice(0, MAX_TIP_ITEMS).map((item: Node) => ( - - - - - - {item.getLabel()} - - - ))} + + + + {items.slice(0, MAX_TIP_ITEMS).map((item: Node) => { + const childState = item.getData()?.pipelineTask.status?.state; + const childRunStatus = item.getData()?.runStatus; + const childStatus = getPipelineTaskStatusLabel(childState, childRunStatus); + const childLabel = item.getLabel(); + return ( + + + + ); + })} {items.length > MAX_TIP_ITEMS ? ( {`... ${items.length - MAX_TIP_ITEMS} others`} ) : null} ); - const status = React.useMemo(() => { - switch (state) { - case ExecutionStateKF.CACHED: - return RunStatus.Succeeded; - case ExecutionStateKF.RUNNING: - return RunStatus.InProgress; - default: - return runStatus; - } - }, [state, runStatus]); + let status = runStatus; + if (state === ExecutionStateKF.CACHED) { + status = RunStatus.Succeeded; + } else if (state === ExecutionStateKF.RUNNING) { + status = RunStatus.InProgress; + } - const iterationCount = element.getData()?.pipelineTask.iterationCount; + const childCount = element.getAllNodeChildren().length; + const pipelineTask = element.getData()?.pipelineTask; + const iterationCount = + pipelineTask && + 'iterationCount' in pipelineTask && + typeof pipelineTask.iterationCount === 'number' + ? pipelineTask.iterationCount + : undefined; const badgeText = iterationCount != null ? `x${iterationCount}` : undefined; + const groupLabel = element.getLabel(); + const groupStatusLabel = getPipelineTaskStatusLabel(state, status); + const groupAriaLabel = groupStatusLabel + ? `${groupLabel} task group, ${childCount} ${ + childCount === 1 ? 'task' : 'tasks' + }, ${groupStatusLabel}` + : `${groupLabel} task group, ${childCount} ${childCount === 1 ? 'task' : 'tasks'}`; + const bounds = element.getBounds(); + // The collapsed popover stops 12 units short of the pill edge to leave the toggle hit target. + const expandedCenterX = bounds.x + bounds.width / 2; + const fallbackExpandedToggle: ControlBounds = { + x: expandedCenterX + EXPANDED_TOGGLE_OFFSET_FROM_CENTER, + y: bounds.y - 13, + width: 28.25, + height: 26, + }; + const expandedToggle = expandedControlBounds?.toggle ?? fallbackExpandedToggle; + const expandedDetails = expandedControlBounds?.details ?? { + x: expandedCenterX - NODE_WIDTH / 2, + y: bounds.y - 13, + width: NODE_WIDTH / 2 + EXPANDED_TOGGLE_OFFSET_FROM_CENTER, + height: 26, + }; + const labelContext = React.useMemo( + () => ({ + groupLabel, + badgeText, + status, + onActionChange: setExpandedAction, + onLabelLayoutChange: updateExpandedControlBounds, + }), + [badgeText, groupLabel, setExpandedAction, status, updateExpandedControlBounds], + ); const groupNode = ( - ( - : undefined} - /> - )} - selected={selected} - onSelect={onSelect} - hideDetailsAtMedium - centerLabelOnEdge - labelPosition={LabelPosition.top} - showStatusState - scaleNode={hover && detailsLevel !== ScaleDetailsLevel.high} - customStatusIcon={status === RunStatus.Cancelled ? : undefined} - showLabelOnHover - status={status} - hiddenDetailsShownStatuses={[ - RunStatus.Succeeded, - RunStatus.Pending, - RunStatus.Failed, - RunStatus.Cancelled, - ]} - collapsedHeight={NODE_HEIGHT} - collapsedWidth={NODE_WIDTH} - /> + + : undefined} + showLabelOnHover + status={status} + hiddenDetailsShownStatuses={[ + RunStatus.Succeeded, + RunStatus.Pending, + RunStatus.Failed, + RunStatus.Cancelled, + ]} + collapsedHeight={NODE_HEIGHT} + collapsedWidth={NODE_WIDTH} + /> + ); return ( - - {element.isCollapsed() ? ( - + {groupNode} + {isCollapsed && !isHidden ? ( + - {groupNode} - - ) : ( - groupNode - )} + setPopoverOpen(true)} + onHide={() => setPopoverOpen(false)} + onHidden={() => setPopoverOpen(false)} + aria-label={groupAriaLabel} + headerContent={groupLabel} + bodyContent={(hidePopover) => + getPopoverTasksList(element.getAllNodeChildren(), hidePopover) + } + > + , + ); + const button = screen.getByRole('button', { name: 'Toggle group' }); + + fireEvent.keyDown(button, { key }); + fireEvent.keyDown(button, { key, repeat: true }); + fireEvent.keyDown(button, { key, repeat: true }); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/concepts/topology/__tests__/a11yUtils.spec.ts b/frontend/src/concepts/topology/__tests__/a11yUtils.spec.ts new file mode 100644 index 00000000000..9c8b6f83ed6 --- /dev/null +++ b/frontend/src/concepts/topology/__tests__/a11yUtils.spec.ts @@ -0,0 +1,214 @@ +import { + DEFAULT_TASK_NODE_TYPE, + pipelineElementFactory, + Visualization, +} from '@patternfly/react-topology'; +import { + isHiddenByCollapsedAncestor, + orderNodesForKeyboard, + panFocusedControlIntoView, +} from '#~/concepts/topology/a11yUtils'; + +describe('orderNodesForKeyboard', () => { + it('should place graph nodes and group children in visual reading order', () => { + const controller = new Visualization(); + controller.registerElementFactory(pipelineElementFactory); + controller.fromModel( + { + graph: { id: 'graph', type: 'graph' }, + nodes: [ + { id: 'z-last', type: DEFAULT_TASK_NODE_TYPE, x: 0, y: 200, width: 130, height: 35 }, + { + id: 'a-group', + type: DEFAULT_TASK_NODE_TYPE, + x: 200, + y: 100, + width: 130, + height: 100, + group: true, + style: { padding: [64, 15] }, + children: ['a-child-last', 'z-child-first'], + }, + { + id: 'a-child-last', + type: DEFAULT_TASK_NODE_TYPE, + x: 200, + y: 160, + width: 130, + height: 35, + }, + { id: 'z-first', type: DEFAULT_TASK_NODE_TYPE, x: 0, y: 100, width: 130, height: 35 }, + { + id: 'z-child-first', + type: DEFAULT_TASK_NODE_TYPE, + x: 200, + y: 100, + width: 130, + height: 35, + }, + ], + }, + false, + ); + + const graph = controller.getGraph(); + orderNodesForKeyboard(graph); + + expect(graph.getNodes().map((node) => node.getId())).toEqual(['z-first', 'a-group', 'z-last']); + expect( + controller + .getNodeById('a-group') + ?.getNodes() + .map((node) => node.getId()), + ).toEqual(['z-child-first', 'a-child-last']); + }); +}); + +describe('isHiddenByCollapsedAncestor', () => { + it('should exclude nodes inside a collapsed group while keeping the group keyboard accessible', () => { + const controller = new Visualization(); + controller.registerElementFactory(pipelineElementFactory); + controller.fromModel( + { + graph: { id: 'graph', type: 'graph' }, + nodes: [ + { + id: 'group', + type: DEFAULT_TASK_NODE_TYPE, + group: true, + collapsed: true, + children: ['child'], + }, + { id: 'child', type: DEFAULT_TASK_NODE_TYPE }, + ], + }, + false, + ); + + const group = controller.getNodeById('group'); + const child = controller.getNodeById('child'); + expect(group).toBeDefined(); + expect(child).toBeDefined(); + if (!group || !child) { + return; + } + + expect(isHiddenByCollapsedAncestor(group)).toBe(false); + expect(isHiddenByCollapsedAncestor(child)).toBe(true); + group.setCollapsed(false); + expect(isHiddenByCollapsedAncestor(child)).toBe(false); + }); +}); + +describe('panFocusedControlIntoView', () => { + it('should pan an offscreen group control into the topology viewport', () => { + const controller = new Visualization(); + controller.registerElementFactory(pipelineElementFactory); + controller.fromModel( + { + graph: { id: 'graph', type: 'graph' }, + nodes: [{ id: 'group', type: DEFAULT_TASK_NODE_TYPE, group: true }], + }, + false, + ); + const group = controller.getNodeById('group'); + expect(group).toBeDefined(); + if (!group) { + return; + } + + const visualization = document.createElement('div'); + visualization.className = 'pipeline-visualization'; + const viewport = document.createElement('div'); + viewport.dataset.testId = 'topology'; + const button = document.createElement('button'); + viewport.append(button); + visualization.append(viewport); + document.body.append(visualization); + jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + left: 100, + right: 500, + top: 100, + bottom: 400, + } as DOMRect); + jest.spyOn(button, 'getBoundingClientRect').mockReturnValue({ + left: 80, + right: 110, + top: 40, + bottom: 70, + } as DOMRect); + + const positionBefore = controller.getGraph().getPosition().clone(); + panFocusedControlIntoView(group, button); + + expect(controller.getGraph().getPosition().x).toBe(positionBefore.x + 40); + expect(controller.getGraph().getPosition().y).toBe(positionBefore.y + 80); + + // A partly clipped control should also move fully inside the padded viewport. + jest.spyOn(button, 'getBoundingClientRect').mockReturnValue({ + left: 110, + right: 240, + top: 360, + bottom: 390, + } as DOMRect); + const positionBeforePartialPan = controller.getGraph().getPosition().clone(); + panFocusedControlIntoView(group, button); + expect(controller.getGraph().getPosition().x).toBe(positionBeforePartialPan.x + 10); + expect(controller.getGraph().getPosition().y).toBe(positionBeforePartialPan.y - 10); + visualization.remove(); + }); + + it('keeps a focused control above the topology control bar', () => { + const controller = new Visualization(); + controller.registerElementFactory(pipelineElementFactory); + controller.fromModel( + { + graph: { id: 'graph', type: 'graph' }, + nodes: [{ id: 'group', type: DEFAULT_TASK_NODE_TYPE, group: true }], + }, + false, + ); + const group = controller.getNodeById('group'); + expect(group).toBeDefined(); + if (!group) { + return; + } + + const visualization = document.createElement('div'); + visualization.className = 'pipeline-visualization'; + const viewport = document.createElement('div'); + viewport.dataset.testId = 'topology'; + const controlBar = document.createElement('div'); + controlBar.dataset.testid = 'pipeline-topology-control-bar'; + const button = document.createElement('button'); + viewport.append(button); + visualization.append(viewport, controlBar); + document.body.append(visualization); + jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + left: 100, + right: 500, + top: 100, + bottom: 400, + } as DOMRect); + jest.spyOn(controlBar, 'getBoundingClientRect').mockReturnValue({ + left: 140, + right: 260, + top: 350, + bottom: 390, + } as DOMRect); + jest.spyOn(button, 'getBoundingClientRect').mockReturnValue({ + left: 80, + right: 110, + top: 360, + bottom: 390, + } as DOMRect); + + const positionBefore = controller.getGraph().getPosition().clone(); + panFocusedControlIntoView(group, button); + + expect(controller.getGraph().getPosition().x).toBe(positionBefore.x + 40); + expect(controller.getGraph().getPosition().y).toBe(positionBefore.y - 60); + + visualization.remove(); + }); +}); diff --git a/frontend/src/concepts/topology/__tests__/utils.spec.ts b/frontend/src/concepts/topology/__tests__/utils.spec.ts new file mode 100644 index 00000000000..6ceb49ceb9e --- /dev/null +++ b/frontend/src/concepts/topology/__tests__/utils.spec.ts @@ -0,0 +1,57 @@ +import { RunStatus } from '@patternfly/react-topology'; +import { ExecutionStateKF } from '#~/concepts/pipelines/kfTypes'; +import { + getExecutionStateLabel, + getPipelineTaskStatusLabel, + getRunStatusLabel, +} from '#~/concepts/topology/utils'; + +describe('getRunStatusLabel', () => { + it.each([ + [RunStatus.Succeeded, 'Complete'], + [RunStatus.Failed, 'Failed'], + [RunStatus.FailedToStart, 'Failed'], + [RunStatus.Cancelled, 'Canceled'], + [RunStatus.Running, 'Running'], + [RunStatus.InProgress, 'Running'], + [RunStatus.Pending, 'Pending'], + [RunStatus.Idle, 'Pending'], + [RunStatus.Skipped, 'Skipped'], + ])('should announce %s as %s', (status, label) => { + expect(getRunStatusLabel(status)).toBe(label); + }); + + it('should omit an unavailable status', () => { + expect(getRunStatusLabel(undefined)).toBe(''); + }); +}); + +describe('getExecutionStateLabel', () => { + it.each([ + [ExecutionStateKF.CACHED, 'Cached'], + [ExecutionStateKF.CANCELING, 'Canceling'], + [ExecutionStateKF.CANCELED, 'Canceled'], + [ExecutionStateKF.SKIPPED, 'Skipped'], + [ExecutionStateKF.PENDING, 'Pending'], + [ExecutionStateKF.RUNNING, 'Running'], + [ExecutionStateKF.COMPLETE, 'Complete'], + [ExecutionStateKF.FAILED, 'Failed'], + [ExecutionStateKF.NEW, undefined], + ])('should announce %s as %s', (state, label) => { + expect(getExecutionStateLabel(state)).toBe(label); + }); +}); + +describe('getPipelineTaskStatusLabel', () => { + it('should announce cached tasks as cached even when their run status is skipped', () => { + expect(getPipelineTaskStatusLabel(ExecutionStateKF.CACHED, RunStatus.Skipped)).toBe('Cached'); + }); + + it('should use run status when no execution state is available', () => { + expect(getPipelineTaskStatusLabel(undefined, RunStatus.Failed)).toBe('Failed'); + }); + + it('should use run status when an execution state has no spoken label', () => { + expect(getPipelineTaskStatusLabel(ExecutionStateKF.NEW, RunStatus.Pending)).toBe('Pending'); + }); +}); diff --git a/frontend/src/concepts/topology/a11yUtils.ts b/frontend/src/concepts/topology/a11yUtils.ts new file mode 100644 index 00000000000..7cfb74238c6 --- /dev/null +++ b/frontend/src/concepts/topology/a11yUtils.ts @@ -0,0 +1,111 @@ +import { action, Graph, isNode, Node } from '@patternfly/react-topology'; + +/** The HTML node button supplies the accessible name and keyboard action. */ +export const hideSvgTaskLabelsFromKeyboard = (container: SVGElement | null): void => { + container + ?.querySelectorAll('.pf-topology-pipelines__pill-text') + .forEach((label) => { + label.setAttribute('tabindex', '-1'); + label.setAttribute('aria-hidden', 'true'); + }); +}; + +/** Keep a focused or selected pipeline node visible inside the graph viewport. */ +export const panNodeIntoView = (element: Node): void => { + element.getGraph().panIntoView(element, { offset: 20, minimumVisible: 100 }); +}; + +/** Keep the whole focused control visible, including when its node is partly in view. */ +export const panFocusedControlIntoView = (element: Node, button: HTMLButtonElement): void => { + const viewport = button.closest('[data-test-id="topology"]'); + if (!viewport) { + panNodeIntoView(element); + return; + } + + const buttonBounds = button.getBoundingClientRect(); + const viewportBounds = viewport.getBoundingClientRect(); + const padding = 20; + const controlBar = button + .closest('.pipeline-visualization') + ?.querySelector('[data-testid="pipeline-topology-control-bar"]'); + const controlBarBounds = controlBar?.getBoundingClientRect(); + const controlBarOverlapsViewport = + controlBarBounds && + controlBarBounds.left < viewportBounds.right && + controlBarBounds.right > viewportBounds.left && + controlBarBounds.top < viewportBounds.bottom && + controlBarBounds.bottom > viewportBounds.top; + const left = viewportBounds.left + padding; + const right = viewportBounds.right - padding; + const top = viewportBounds.top + padding; + // Reserve the control bar's full bottom strip: horizontal panning can move a button into it. + const bottom = controlBarOverlapsViewport + ? Math.min(viewportBounds.bottom - padding, controlBarBounds.top - padding) + : viewportBounds.bottom - padding; + const x = + buttonBounds.left < left + ? left - buttonBounds.left + : buttonBounds.right > right + ? right - buttonBounds.right + : 0; + const y = + buttonBounds.top < top + ? top - buttonBounds.top + : buttonBounds.bottom > bottom + ? bottom - buttonBounds.bottom + : 0; + + if (x || y) { + const graph = element.getGraph(); + graph.setPosition(graph.getPosition().clone().translate(x, y)); + } +}; + +/** Returns true when a collapsed ancestor hides the node from keyboard navigation. */ +export const isHiddenByCollapsedAncestor = (element: Node): boolean => { + const graph = element.getGraph(); + let parent = element.getParent(); + while (parent !== graph) { + if (isNode(parent) && parent.isCollapsed()) { + return true; + } + parent = parent.getParent(); + } + return false; +}; + +const getReadingOrderPosition = (node: Node): { x: number; y: number } => { + const bounds = node.getBounds(); + if (!node.isGroup() || node.isCollapsed()) { + return bounds; + } + + const visibleChildren = node.getNodes().filter((child) => child.isVisible()); + if (!visibleChildren.length) { + return bounds; + } + + return visibleChildren + .map(getReadingOrderPosition) + .reduce((first, child) => + child.y < first.y || (child.y === first.y && child.x < first.x) ? child : first, + ); +}; + +/** Keep the graph's DOM order aligned with the laid-out pipeline, including nested groups. */ +export const orderNodesForKeyboard = action((parent: Graph | Node): void => { + const nodes = parent.getNodes(); + const orderedNodes = nodes.toSorted((a, b) => { + const aPosition = getReadingOrderPosition(a); + const bPosition = getReadingOrderPosition(b); + return ( + aPosition.y - bPosition.y || aPosition.x - bPosition.x || a.getId().localeCompare(b.getId()) + ); + }); + + if (nodes.some((node, index) => node !== orderedNodes[index])) { + orderedNodes.forEach((node) => parent.appendChild(node)); + } + orderedNodes.filter((node) => node.isGroup()).forEach(orderNodesForKeyboard); +}); diff --git a/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx b/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx index c2986233540..44cea3fa820 100644 --- a/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx +++ b/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx @@ -8,6 +8,7 @@ import { WithSelectionProps, observer, useAnchor, + useCombineRefs, AnchorEnd, getRunStatusModifier, ScaleDetailsLevel, @@ -22,6 +23,9 @@ import { TaskNodeProps } from '@patternfly/react-topology/dist/esm/pipelines/com import { css } from '@patternfly/react-styles'; import { StandardTaskNodeData } from '#~/concepts/topology/types'; import { isMetricsArtifactType } from '#~/concepts/pipelines/content/pipelinesDetails/pipelineRun/artifacts/utils'; +import { getRunStatusLabel } from '#~/concepts/topology/utils'; +import PipelineNodeButton from '#~/concepts/topology/PipelineNodeButton'; +import { hideSvgTaskLabelsFromKeyboard } from '#~/concepts/topology/a11yUtils'; const ICON_PADDING = 8; @@ -53,6 +57,7 @@ const IconTaskNode: React.FC = observer(({ element, selected, return ( = observer(({ element, selected, } transform={`translate(${(bounds.width - iconSize) / 2}, ${ICON_PADDING})`} color={ - // Need insight from product dev as to how to view this component to test the colors selected ? 'var(--pf-t--global--icon--color--inverse)' : 'var(--pf-t--global--icon--color--subtle)' @@ -105,6 +109,10 @@ const ArtifactTaskNodeInner: React.FC = observer( ({ element, selected, onSelect, ...rest }) => { const bounds = element.getBounds(); const [isHover, hoverRef] = useHover(); + const [isFocused, setIsFocused] = React.useState(false); + const nodeRef = React.useRef(null); + const combinedRef = useCombineRefs(hoverRef, nodeRef); + React.useLayoutEffect(() => hideSvgTaskLabelsFromKeyboard(nodeRef.current)); const detailsLevel = element.getGraph().getDetailsLevel(); const data = element.getData(); const scale = element.getGraph().getScale(); @@ -122,9 +130,18 @@ const ArtifactTaskNodeInner: React.FC = observer( const translateX = bounds.width / 2 - (iconSize / 2) * upScale; const translateY = iconPadding * upScale; + + const statusLabel = getRunStatusLabel(data?.runStatus); + const taskName = element.getLabel(); + const ariaLabel = statusLabel ? `${taskName}, ${statusLabel}` : taskName; + return ( - - {isHover || detailsLevel !== ScaleDetailsLevel.high ? ( + + {isHover || isFocused || detailsLevel !== ScaleDetailsLevel.high ? ( = observer( onSelect={onSelect} hiddenDetailsShownStatuses={[]} status={data?.runStatus} - scaleNode={isHover} + scaleNode={isHover || isFocused} {...rest} > {whenDecorator} - {!isHover ? ( + {!isHover && !isFocused ? ( = observer( ) : ( )} + ); }, diff --git a/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx b/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx index 1a6e1dd1121..11493b45c49 100644 --- a/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx +++ b/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx @@ -3,10 +3,14 @@ import { DEFAULT_WHEN_OFFSET, DEFAULT_WHEN_SIZE, GraphElement, + isNode, + Node, + NodeModel, observer, RunStatus, ScaleDetailsLevel, TaskNode, + useCombineRefs, useHover, WhenDecorator, WithContextMenuProps, @@ -19,83 +23,103 @@ import { PendingIcon, SyncAltIcon, } from '@patternfly/react-icons'; -import { PipelineNodeModelExpanded } from '#~/concepts/topology/types'; +import { PipelineNodeModelExpanded, StandardTaskNodeData } from '#~/concepts/topology/types'; import { ExecutionStateKF } from '#~/concepts/pipelines/kfTypes'; +import { getPipelineTaskStatusLabel } from '#~/concepts/topology/utils'; +import PipelineNodeButton from '#~/concepts/topology/PipelineNodeButton'; +import { hideSvgTaskLabelsFromKeyboard } from '#~/concepts/topology/a11yUtils'; -type StandardTaskNodeProps = { - element: GraphElement; +type StandardTaskNodeInnerProps = { + element: Node; } & WithContextMenuProps & WithSelectionProps; -const StandardTaskNode: React.FunctionComponent = ({ - element, - onSelect, - selected, - ...rest -}) => { - const data = element.getData(); - const [hover, hoverRef] = useHover(); - const detailsLevel = element.getGraph().getDetailsLevel(); - const state = data?.pipelineTask.status?.state; +const StandardTaskNodeInner: React.FunctionComponent = observer( + ({ element, onSelect, selected, ...rest }) => { + const data = element.getData(); + const [hover, hoverRef] = useHover(); + const nodeRef = React.useRef(null); + const combinedRef = useCombineRefs(hoverRef, nodeRef); + React.useLayoutEffect(() => hideSvgTaskLabelsFromKeyboard(nodeRef.current)); + const detailsLevel = element.getGraph().getDetailsLevel(); + const state = data?.pipelineTask.status?.state; + + const status = React.useMemo(() => { + switch (state) { + case ExecutionStateKF.CACHED: + return RunStatus.Succeeded; + case ExecutionStateKF.RUNNING: + return RunStatus.InProgress; + default: + return data?.runStatus; + } + }, [state, data?.runStatus]); + + const customStatusIcon = React.useMemo(() => { + switch (state) { + case ExecutionStateKF.CANCELED: + return ; + case ExecutionStateKF.CANCELING: + return ; + case ExecutionStateKF.CACHED: + return ; + case ExecutionStateKF.SKIPPED: + return ; + case ExecutionStateKF.PENDING: + return ; + default: + return undefined; + } + }, [state]); + + const statusLabel = getPipelineTaskStatusLabel(state, status); + const taskName = element.getLabel(); + const ariaLabel = statusLabel ? `${taskName}, ${statusLabel}` : taskName; - const status = React.useMemo(() => { - switch (state) { - case ExecutionStateKF.CACHED: - return RunStatus.Succeeded; - case ExecutionStateKF.RUNNING: - return RunStatus.InProgress; - default: - return data?.runStatus; - } - }, [state, data?.runStatus]); + return ( + + + {data?.pipelineTask.whenStatus && ( + + )} + + + + ); + }, +); - const customStatusIcon = React.useMemo(() => { - switch (state) { - case ExecutionStateKF.CANCELED: - return ; - case ExecutionStateKF.CANCELING: - return ; - case ExecutionStateKF.CACHED: - return ; - case ExecutionStateKF.SKIPPED: - return ; - case ExecutionStateKF.PENDING: - return ; - default: - return undefined; - } - }, [state]); +type StandardTaskNodeProps = { + element: GraphElement; +} & WithContextMenuProps & + WithSelectionProps; - return ( - - - {data?.pipelineTask.whenStatus && ( - - )} - - - ); +const StandardTaskNode: React.FunctionComponent = ({ element, ...rest }) => { + if (!isNode(element)) { + throw new Error('StandardTaskNode must be used only on Node elements'); + } + return ; }; export default observer(StandardTaskNode); diff --git a/frontend/src/concepts/topology/factories.ts b/frontend/src/concepts/topology/factories.ts index a654c10e0a2..a94a36bb079 100644 --- a/frontend/src/concepts/topology/factories.ts +++ b/frontend/src/concepts/topology/factories.ts @@ -16,21 +16,24 @@ import PipelineTaskEdge from './PipelineTaskEdge'; import PipelineDefaultTaskGroup from './PipelineDefaultTaskGroup'; import { EXECUTION_TASK_NODE_TYPE } from './const'; +// PatternFly raises selected nodes to the end of their parent, which changes keyboard order. +const withOrderedSelection = withSelection({ raiseOnSelect: false }); + export const pipelineComponentFactory: ComponentFactory = (kind, type) => { if (kind === ModelKind.graph) { return withPanZoom()(withSelection()(GraphComponent)); } switch (type) { case DEFAULT_TASK_NODE_TYPE: - return withSelection()(StandardTaskNode); + return withOrderedSelection(StandardTaskNode); case ICON_TASK_NODE_TYPE: - return withSelection()(ArtifactTaskNode); + return withOrderedSelection(ArtifactTaskNode); case DEFAULT_SPACER_NODE_TYPE: return SpacerNode; case DEFAULT_EDGE_TYPE: return withSelection()(PipelineTaskEdge); case EXECUTION_TASK_NODE_TYPE: - return withSelection()(PipelineDefaultTaskGroup); + return withOrderedSelection(PipelineDefaultTaskGroup); default: return undefined; } diff --git a/frontend/src/concepts/topology/useTopologyController.ts b/frontend/src/concepts/topology/useTopologyController.ts index 22c184ac7ab..5a1c97bc5a8 100644 --- a/frontend/src/concepts/topology/useTopologyController.ts +++ b/frontend/src/concepts/topology/useTopologyController.ts @@ -8,6 +8,7 @@ import { Visualization, } from '@patternfly/react-topology'; import { pipelineComponentFactory } from './factories'; +import { orderNodesForKeyboard } from './a11yUtils'; import { PIPELINE_LAYOUT, PIPELINE_NODE_SEPARATION_HORIZONTAL, @@ -44,6 +45,7 @@ const useTopologyController = (graphId: string): Visualization | null => { false, ); const onGraphLayoutEnd = () => { + orderNodesForKeyboard(visualizationController.getGraph()); visualizationController.getGraph().fit(75); }; visualizationController.addEventListener(GRAPH_LAYOUT_END_EVENT, onGraphLayoutEnd); diff --git a/frontend/src/concepts/topology/utils.ts b/frontend/src/concepts/topology/utils.ts index 9aead87c234..3c6441293b7 100644 --- a/frontend/src/concepts/topology/utils.ts +++ b/frontend/src/concepts/topology/utils.ts @@ -1,10 +1,68 @@ import { DEFAULT_TASK_NODE_TYPE, RunStatus } from '@patternfly/react-topology'; import { PipelineTask } from '#~/concepts/pipelines/topology'; +import { ExecutionStateKF } from '#~/concepts/pipelines/kfTypes'; import { EXECUTION_TASK_NODE_TYPE, NODE_HEIGHT, NODE_WIDTH } from './const'; import { PipelineNodeModelExpanded } from './types'; export const ICON_TASK_NODE_TYPE = 'ICON_TASK_NODE'; +/** Human-readable label for a PF topology RunStatus value. */ +export const getRunStatusLabel = (status: RunStatus | string | undefined): string => { + switch (status) { + case RunStatus.Succeeded: + return 'Complete'; + case RunStatus.Failed: + case RunStatus.FailedToStart: + return 'Failed'; + case RunStatus.Cancelled: + return 'Canceled'; + case RunStatus.Running: + case RunStatus.InProgress: + return 'Running'; + case RunStatus.Pending: + case RunStatus.Idle: + return 'Pending'; + case RunStatus.Skipped: + return 'Skipped'; + default: + return ''; + } +}; + +/** + * Human-readable label for a KF ExecutionStateKF value. + * Returns undefined when the state does not override the RunStatus label. + */ +export const getExecutionStateLabel = ( + state: ExecutionStateKF | string | undefined, +): string | undefined => { + switch (state) { + case ExecutionStateKF.CACHED: + return 'Cached'; + case ExecutionStateKF.CANCELING: + return 'Canceling'; + case ExecutionStateKF.CANCELED: + return 'Canceled'; + case ExecutionStateKF.SKIPPED: + return 'Skipped'; + case ExecutionStateKF.PENDING: + return 'Pending'; + case ExecutionStateKF.RUNNING: + return 'Running'; + case ExecutionStateKF.COMPLETE: + return 'Complete'; + case ExecutionStateKF.FAILED: + return 'Failed'; + default: + return undefined; + } +}; + +export const getPipelineTaskStatusLabel = ( + state: ExecutionStateKF | string | undefined, + runStatus: RunStatus | string | undefined, +): string => getExecutionStateLabel(state) ?? getRunStatusLabel(runStatus); + export const ARTIFACT_NODE_WIDTH = 44; export const ARTIFACT_NODE_HEIGHT = NODE_HEIGHT; diff --git a/packages/cypress/cypress/pages/pipelines/topology.ts b/packages/cypress/cypress/pages/pipelines/topology.ts index cc928282aba..1dbf031484c 100644 --- a/packages/cypress/cypress/pages/pipelines/topology.ts +++ b/packages/cypress/cypress/pages/pipelines/topology.ts @@ -43,7 +43,7 @@ class TaskDrawer extends Contextual { } shouldHaveTaskName(name: string) { - return this.find().findByTestId('pipeline-task-name').should('contain.text', name); + return this.find().findByTestId('pipeline-drawer-task-title').should('contain.text', name); } } @@ -63,6 +63,59 @@ class PipelinesTopology { return cy.get(`[data-id="${name}"][data-kind="node"][data-type="DEFAULT_TASK_NODE"]`); } + findTaskButton(name: string) { + return cy.findByTestId(`pipeline-node-button-${name}`); + } + + findGroupToggle(name: string) { + return cy.findByTestId(`pipeline-group-toggle-${name}`); + } + + findGroupActionIcon(name: string) { + // PatternFly owns this SVG action icon; scope its class selector to the named group. + return cy.findByTestId(`pipeline-group-label-${name}`).find('.pf-topology__node__action-icon'); + } + + findGroupButton(name: string) { + return cy.findByTestId(`pipeline-group-button-${name}`); + } + + findGroupTask(name: string) { + return cy.findByTestId(`pipeline-group-task-${name}`); + } + + findGroupDetails(name: string) { + return cy.findByTestId(`pipeline-group-details-${name}`); + } + + findExpandedGroupDetails(name: string) { + return cy.findByTestId(`pipeline-expanded-group-details-${name}`); + } + + findGraphButtons() { + return cy.get( + 'button[data-testid^="pipeline-node-button-"], ' + + 'button[data-testid^="pipeline-group-button-"], ' + + 'button[data-testid^="pipeline-group-toggle-"], ' + + 'button[data-testid^="pipeline-expanded-group-details-"]', + ); + } + + findGroupLabelText(name: string) { + // PatternFly owns the SVG pill text; scope its class to the named group label. + return cy + .findByTestId(`pipeline-group-label-${name}`) + .find('.pf-topology-pipelines__pill-text'); + } + + findDrawerTaskTitle() { + return cy.findByTestId('pipeline-drawer-task-title'); + } + + findDrawerCloseButton() { + return cy.findByRole('button', { name: 'Close drawer panel' }); + } + findTaskNodes() { return cy.get('[data-kind="node"][data-type="DEFAULT_TASK_NODE"]'); } @@ -114,6 +167,14 @@ class PipelinesTopology { `[data-id="GROUP.root.ARTIFACT.${name}"][data-kind="node"][data-type="ICON_TASK_NODE"]`, ); } + + findArtifactIcon(name: string) { + return cy.findByTestId(`pipeline-artifact-icon-${name}`); + } + + findArtifactVisual(name: string) { + return cy.findByTestId(`pipeline-artifact-visual-${name}`); + } } class PipelineRunRightDrawer extends Contextual { diff --git a/packages/cypress/cypress/tests/mocked/pipelines/pipelineGraphEdgeCases.cy.ts b/packages/cypress/cypress/tests/mocked/pipelines/pipelineGraphEdgeCases.cy.ts index aaef8c201ad..062cf7b22dc 100644 --- a/packages/cypress/cypress/tests/mocked/pipelines/pipelineGraphEdgeCases.cy.ts +++ b/packages/cypress/cypress/tests/mocked/pipelines/pipelineGraphEdgeCases.cy.ts @@ -246,6 +246,35 @@ describe('Pipeline Graph Edge Cases', () => { pipelineDetails.findTaskNodes().should('have.length', 25); }); + + it('pans an offscreen task into view when it receives keyboard focus', () => { + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockLargePipeline.pipeline_version_id, + ); + + for (let i = 0; i < 8; i++) { + pipelineDetails.findZoomInButton().click(); + } + + pipelineDetails.findVisualizationSurface().then(($graph) => { + const graphBounds = $graph[0].getBoundingClientRect(); + pipelineDetails.findTaskButton('task-15').should(($button) => { + const buttonBounds = $button[0].getBoundingClientRect(); + expect( + buttonBounds.bottom < graphBounds.top || buttonBounds.top > graphBounds.bottom, + ).to.equal(true); + }); + + pipelineDetails.findTaskButton('task-15').focus().should('be.focused'); + pipelineDetails.findTaskButton('task-15').should(($button) => { + const buttonBounds = $button[0].getBoundingClientRect(); + expect(buttonBounds.top).to.be.at.least(graphBounds.top); + expect(buttonBounds.bottom).to.be.at.most(graphBounds.bottom); + }); + }); + }); }); describe('Error Cases', () => { diff --git a/packages/cypress/cypress/tests/mocked/pipelines/topology/pipelinesTopology.cy.ts b/packages/cypress/cypress/tests/mocked/pipelines/topology/pipelinesTopology.cy.ts index 78818aae0c4..1fb78d08591 100644 --- a/packages/cypress/cypress/tests/mocked/pipelines/topology/pipelinesTopology.cy.ts +++ b/packages/cypress/cypress/tests/mocked/pipelines/topology/pipelinesTopology.cy.ts @@ -19,6 +19,10 @@ import { RecurringRunStatus, RuntimeStateKF, } from '@odh-dashboard/internal/concepts/pipelines/kfTypes'; +import { + mockParallelForPipelineSpec, + mockParallelForWithArtifactsPipelineSpec, +} from '@odh-dashboard/internal/concepts/pipelines/topology/__tests__/mockParallelForPipelineSpec'; import { DataScienceStackComponent } from '@odh-dashboard/plugin-core/areas'; import { SecretModel, PodModel } from '@odh-dashboard/k8s-core/api/models'; import { @@ -53,6 +57,28 @@ const mockVersion2 = buildMockPipelineVersion({ pipeline_version_id: 'test-version-id-2', display_name: 'test-version-2', }); +const mockGroupedVersion = buildMockPipelineVersion({ + pipeline_id: mockPipeline.pipeline_id, + display_name: 'grouped-pipeline-version', +}); +mockGroupedVersion.pipeline_spec = mockParallelForPipelineSpec; +const longGroupName = 'for-loop-with-many-iterations'; +const mockLongNamedGroupedVersion = buildMockPipelineVersion({ + pipeline_id: mockPipeline.pipeline_id, + display_name: 'long-named-grouped-pipeline-version', +}); +mockLongNamedGroupedVersion.pipeline_spec = structuredClone( + mockParallelForWithArtifactsPipelineSpec, +); +const longNamedGroupTasks = mockLongNamedGroupedVersion.pipeline_spec.root?.dag.tasks; +if (!longNamedGroupTasks) { + throw new Error('Expected grouped pipeline tasks'); +} +longNamedGroupTasks[longGroupName] = { + ...longNamedGroupTasks['for-loop-2'], + taskInfo: { name: longGroupName }, +}; +delete longNamedGroupTasks['for-loop-2']; const mockRun = buildMockRunKF({ display_name: 'test-pipeline-run', run_id: 'test-pipeline-run-id', @@ -68,6 +94,15 @@ const mockRun = buildMockRunKF({ pipeline_version_id: mockVersion.pipeline_version_id, }, }); +const mockRunningGroupedRun = buildMockRunKF({ + ...mockRun, + finished_at: undefined, + state: RuntimeStateKF.RUNNING, + pipeline_version_reference: { + pipeline_id: mockPipeline.pipeline_id, + pipeline_version_id: mockGroupedVersion.pipeline_version_id, + }, +}); const mockRecurringRun = buildMockRecurringRunKF({ display_name: 'test-pipeline', recurring_run_id: 'test-pipeline', @@ -78,7 +113,7 @@ const mockRecurringRun = buildMockRecurringRunKF({ experiment_id: 'test-experiment', }); -const initIntercepts = () => { +const initIntercepts = (pipelineVersion = mockVersion, run = mockRun) => { cy.interceptOdh( 'GET /api/dsc/status', mockDscStatus({ @@ -133,7 +168,9 @@ const initIntercepts = () => { cy.interceptOdh( 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions', { path: { namespace: projectId, serviceName: 'dspa', pipelineId: mockPipeline.pipeline_id } }, - buildMockPipelineVersions([mockVersion, mockVersion2]), + buildMockPipelineVersions( + pipelineVersion === mockVersion ? [mockVersion, mockVersion2] : [pipelineVersion], + ), ); cy.interceptOdh( 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/recurringruns/:recurringRunId', @@ -151,8 +188,8 @@ const initIntercepts = () => { { path: { namespace: projectId, serviceName: 'dspa', runId: mockRun.run_id }, }, - mockRun, - ); + run, + ).as('getPipelineRun'); cy.interceptOdh( 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions/:pipelineVersionId', { @@ -160,10 +197,10 @@ const initIntercepts = () => { namespace: projectId, serviceName: 'dspa', pipelineId: mockPipeline.pipeline_id, - pipelineVersionId: mockVersion.pipeline_version_id, + pipelineVersionId: pipelineVersion.pipeline_version_id, }, }, - mockVersion, + pipelineVersion, ); cy.interceptOdh( 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/experiments/:experimentId', @@ -263,6 +300,14 @@ describe('Pipeline topology', () => { taskDrawer.find().should('not.exist'); }); + it('moves focus to task details and back when opened from the keyboard', () => { + pipelineDetails.findTaskButton('create-dataset').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findDrawerTaskTitle().should('be.focused'); + pipelineDetails.findDrawerCloseButton().click(); + pipelineDetails.findTaskButton('create-dataset').should('be.focused'); + }); + it('delete pipeline version from action dropdown', () => { pipelineDetails.selectActionDropdownItem('Delete pipeline version'); deleteModal.shouldBeOpen(); @@ -350,7 +395,319 @@ describe('Pipeline topology', () => { }); }); + describe('Pipeline group keyboard interaction', () => { + it('opens the collapsed group task popover from the keyboard', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupButton('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupButton('for-loop-2').should('have.attr', 'aria-expanded', 'true'); + cy.press(Cypress.Keyboard.Keys.ESC); + pipelineDetails.findGroupButton('for-loop-2').should('have.attr', 'aria-expanded', 'false'); + }); + + it('focuses the drawer for a task selected from a collapsed group', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupButton('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupTask('simple-task').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupTask('simple-task').should('not.exist'); + // PatternFly may return focus to the group's trigger as the popover closes. + pipelineDetails.findGroupButton('for-loop-2').focus(); + pipelineDetails.findDrawerTaskTitle().should('be.focused'); + pipelineDetails.findDrawerCloseButton().click(); + pipelineDetails.findGroupButton('for-loop-2').should('be.focused'); + }); + + it('focuses the open drawer when the same collapsed-group task is selected again', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupButton('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupTask('simple-task').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findDrawerTaskTitle().should('be.focused'); + + pipelineDetails.findGroupButton('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupTask('simple-task').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findDrawerTaskTitle().should('be.focused'); + }); + + it('keeps focus on the group toggle after leaving a selected task', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupButton('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupTask('simple-task').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupTask('simple-task').should('not.exist'); + pipelineDetails.findGroupToggle('for-loop-2').focus(); + // The drawer focus effect waits 550 ms. Fake timers break Monaco in pipeline details. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(600); + pipelineDetails.findGroupToggle('for-loop-2').should('be.focused'); + }); + + it('opens group details from the collapsed-group popover', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupButton('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findGroupDetails('for-loop-2').focus(); + pipelineDetails + .findGroupDetails('for-loop-2') + .should('have.attr', 'aria-label', 'View group details for for-loop-2'); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findDrawerTaskTitle().should('be.focused'); + pipelineDetails.findDrawerCloseButton().click(); + pipelineDetails.findGroupButton('for-loop-2').should('be.focused'); + }); + + it('opens expanded group details directly from the keyboard', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupToggle('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findExpandedGroupDetails('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findDrawerTaskTitle().should('be.focused'); + pipelineDetails.findDrawerCloseButton().click(); + pipelineDetails.findExpandedGroupDetails('for-loop-2').should('be.focused'); + pipelineDetails.findTaskButton('simple-task').should('exist'); + }); + + it('uses PatternFly collapse behavior from the keyboard accessible control', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupToggle('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails + .findGroupToggle('for-loop-2') + .should('have.attr', 'aria-expanded', 'true') + .and('be.focused'); + pipelineDetails.findTaskButton('simple-task').should('exist'); + + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails + .findGroupToggle('for-loop-2') + .should('have.attr', 'aria-expanded', 'false') + .and('be.focused'); + pipelineDetails.findTaskButton('simple-task').should('not.exist'); + }); + + it('keeps the expanded group toggle aligned with its SVG action for a long label', () => { + initIntercepts(mockLongNamedGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockLongNamedGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupToggle(longGroupName).focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails.findTaskButton('create-features').should('exist'); + const assertToggleAlignment = () => { + pipelineDetails.findGroupActionIcon(longGroupName).should('have.length', 1); + pipelineDetails.findGroupToggle(longGroupName).then(($toggle) => { + pipelineDetails.findGroupActionIcon(longGroupName).then(($action) => { + const toggle = $toggle[0].getBoundingClientRect(); + const action = $action[0].getBoundingClientRect(); + expect( + Math.min(toggle.right, action.right) - Math.max(toggle.left, action.left), + ).to.be.greaterThan(0); + expect( + Math.min(toggle.bottom, action.bottom) - Math.max(toggle.top, action.top), + ).to.be.greaterThan(0); + }); + }); + }; + assertToggleAlignment(); + for (let i = 0; i < 5; i++) { + pipelineDetails.findZoomOutButton().click(); + } + pipelineDetails.findGroupToggle(longGroupName).focus(); + assertToggleAlignment(); + }); + + it('keeps the expanded group label out of keyboard navigation', () => { + initIntercepts(mockGroupedVersion); + pipelineDetails.visit( + projectId, + mockPipeline.pipeline_id, + mockGroupedVersion.pipeline_version_id, + ); + + pipelineDetails.findGroupToggle('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineDetails + .findGroupLabelText('for-loop-2') + .should('have.attr', 'tabindex', '-1') + .and('have.attr', 'aria-hidden', 'true'); + }); + }); + describe('Pipeline run details', () => { + it('keeps focus on the group toggle after collapse and a run poll', () => { + initIntercepts(mockGroupedVersion, mockRunningGroupedRun); + cy.on('window:before:load', (win) => { + Object.assign(win, { FAST_POLL_INTERVAL: 3000 }); + }); + pipelineRunDetails.visit(projectId, mockRunningGroupedRun.run_id); + cy.wait('@getPipelineRun'); + + pipelineRunDetails.findGroupToggle('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + // Pointer selection does not schedule the keyboard focus effect before the group collapses. + pipelineRunDetails.findTaskNode('simple-task').click(); + pipelineRunDetails.findDrawerTaskTitle().should('exist'); + pipelineRunDetails.findGroupToggle('for-loop-2').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineRunDetails.findTaskButton('simple-task').should('not.exist'); + + // Collapsing can re-render the surface; neither that nor the next poll should reclaim focus. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(600); + pipelineRunDetails.findGroupToggle('for-loop-2').should('be.focused'); + cy.wait('@getPipelineRun'); + // Wait past the focus timeout after the next run update, too. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(600); + pipelineRunDetails.findGroupToggle('for-loop-2').should('be.focused'); + }); + + it('tabs directly between accessible task buttons without stopping on SVG labels', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + pipelineRunDetails.findGraphButtons().then(($buttons) => { + cy.wrap($buttons[0]).focus(); + cy.press(Cypress.Keyboard.Keys.TAB); + cy.wrap($buttons[1]).should('be.focused'); + }); + }); + + it('does not move focus to the drawer after a pointer selection', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + pipelineRunDetails.findTaskNode('create-dataset').click(); + pipelineRunDetails.findDrawerTaskTitle().should('exist'); + // The drawer focus effect runs 550 ms after selection. A fake clock breaks + // Monaco's background tokenizer, so let the browser timer finish. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(600); + pipelineRunDetails.findDrawerTaskTitle().should('not.be.focused'); + }); + + it('does not reclaim focus after the user tabs away from a selected task', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + pipelineRunDetails.findGraphButtons().then(($buttons) => { + cy.wrap($buttons[0]).focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + cy.press(Cypress.Keyboard.Keys.TAB); + cy.wrap($buttons[1]).should('be.focused'); + // The drawer focus effect waits 550 ms. Fake timers break Monaco in run details. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(600); + cy.wrap($buttons[1]).should('be.focused'); + }); + }); + + it('returns focus to the graph step after closing its details', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + pipelineRunDetails.findTaskButton('create-dataset').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineRunDetails.findDrawerTaskTitle().should('be.focused'); + pipelineRunDetails.findDrawerCloseButton().click(); + pipelineRunDetails.findTaskButton('create-dataset').should('be.focused'); + }); + + it('opens artifact details from the keyboard and returns focus', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + pipelineRunDetails.findTaskButton('iris_dataset (Type: Dataset)').focus(); + cy.press(Cypress.Keyboard.Keys.SPACE); + pipelineRunDetails.findDrawerTaskTitle().should('be.focused'); + pipelineRunDetails.findDrawerCloseButton().click(); + pipelineRunDetails.findTaskButton('iris_dataset (Type: Dataset)').should('be.focused'); + }); + + it('shows an artifact label on focus and keeps focus through hover', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + for (let i = 0; i < 8; i++) { + pipelineRunDetails.findZoomInButton().click(); + } + + pipelineRunDetails.findArtifactIcon('iris_dataset (Type: Dataset)').should('exist'); + pipelineRunDetails.findTaskButton('iris_dataset (Type: Dataset)').focus(); + pipelineRunDetails.findArtifactIcon('iris_dataset (Type: Dataset)').should('not.exist'); + pipelineRunDetails.findArtifactVisual('iris_dataset (Type: Dataset)').trigger('mouseenter'); + pipelineRunDetails.findArtifactIcon('iris_dataset (Type: Dataset)').should('not.exist'); + pipelineRunDetails.findTaskButton('iris_dataset (Type: Dataset)').should('be.focused'); + }); + + it('preserves graph keyboard order after selecting a step', () => { + initIntercepts(); + pipelineRunDetails.visit(projectId, mockRun.run_id); + + pipelineRunDetails.findGraphButtons().then(($buttons) => { + const initialOrder = [...$buttons].map((button) => button.dataset.pipelineNodeId); + pipelineRunDetails.findTaskNode('create-dataset').click(); + pipelineRunDetails.findDrawerCloseButton().click(); + pipelineRunDetails.findGraphButtons().should(($currentButtons) => { + expect([...$currentButtons].map((button) => button.dataset.pipelineNodeId)).to.deep.equal( + initialOrder, + ); + }); + }); + }); + describe('Navigation', () => { beforeEach(() => { initIntercepts(); @@ -540,6 +897,17 @@ describe('Pipeline topology', () => { taskDrawer.find().should('not.exist'); }); + it('moves focus to task details and back when opened from the keyboard', () => { + initIntercepts(); + pipelineRecurringRunDetails.visit(projectId, mockRecurringRun.recurring_run_id); + + pipelineRecurringRunDetails.findTaskButton('create-dataset').focus(); + cy.press(Cypress.Keyboard.Keys.ENTER); + pipelineRecurringRunDetails.findDrawerTaskTitle().should('be.focused'); + pipelineRecurringRunDetails.findDrawerCloseButton().click(); + pipelineRecurringRunDetails.findTaskButton('create-dataset').should('be.focused'); + }); + it('Test pipeline triggered run tab details', () => { initIntercepts(); diff --git a/packages/cypress/resources/pipelines_samples/pipeline_a11y_scenarios.py b/packages/cypress/resources/pipelines_samples/pipeline_a11y_scenarios.py new file mode 100644 index 00000000000..02079705eeb --- /dev/null +++ b/packages/cypress/resources/pipelines_samples/pipeline_a11y_scenarios.py @@ -0,0 +1,98 @@ +"""Compile an importable KFP v2 pipeline for pipeline run accessibility checks. + +Run this file with kfp==2.14.2 to regenerate pipeline_a11y_scenarios.yaml. +The default run intentionally fails and leaves its successor unexecuted. +Root components use distinct names so their task IDs remain unique beside nested steps. +""" + +from pathlib import Path + +from kfp import compiler, dsl + + +BASE_IMAGE = "registry.access.redhat.com/ubi9/ubi-minimal:9.6" + + +@dsl.container_component +def report_step(message: str): + return dsl.ContainerSpec( + image=BASE_IMAGE, + command=["sh", "-c"], + args=['printf "Step: %s\\n" "$0"', message], + ) + + +@dsl.container_component +def start_step(): + return dsl.ContainerSpec( + image=BASE_IMAGE, + command=["sh", "-c"], + args=['printf "Step: Start\\n"'], + ) + + +@dsl.container_component +def parallel_check(): + return dsl.ContainerSpec( + image=BASE_IMAGE, + command=["sh", "-c"], + args=['printf "Step: Parallel check\\n"'], + ) + + +@dsl.container_component +def skipped_successor(): + return dsl.ContainerSpec( + image=BASE_IMAGE, + command=["sh", "-c"], + args=['printf "This step should be skipped after the intentional failure\\n"'], + ) + + +@dsl.container_component +def write_artifact(output: dsl.Output[dsl.Artifact]): + return dsl.ContainerSpec( + image=BASE_IMAGE, + command=["sh", "-c"], + args=['printf "artifact for accessibility testing\\n" > "$0"', output.path], + ) + + +@dsl.container_component +def fail_on_purpose(): + return dsl.ContainerSpec( + image=BASE_IMAGE, + command=["sh", "-c"], + args=['echo "Intentional failure for accessibility testing" >&2; exit 42'], + ) + + +@dsl.pipeline(name="multi-substep-phase") +def multi_substep_phase(): + first = report_step(message="Nested sub-step one: prepare").set_display_name("Prepare inputs") + second = report_step(message="Nested sub-step two: transform").after(first) + second.set_display_name("Transform inputs") + report_step(message="Nested sub-step three: verify").after(second).set_display_name( + "Verify transformation" + ) + + +@dsl.pipeline( + name="pipeline-a11y-scenarios", + description="Success, parallel work, nested sub-steps, an artifact, and an intentional failure.", +) +def pipeline_a11y_scenarios(): + start = start_step().set_display_name("Start") + nested = multi_substep_phase().after(start).set_display_name("Three sub-step group") + parallel = parallel_check().after(start) + parallel.set_display_name("Parallel check") + artifact = write_artifact().after(nested).set_display_name("Produce artifact") + failed = fail_on_purpose().after(artifact, parallel).set_display_name("Intentional failure") + skipped_successor().after(failed).set_display_name("Skipped after failure") + + +if __name__ == "__main__": + compiler.Compiler().compile( + pipeline_a11y_scenarios, + package_path=str(Path(__file__).with_suffix(".yaml")), + ) diff --git a/packages/cypress/resources/pipelines_samples/pipeline_a11y_scenarios.yaml b/packages/cypress/resources/pipelines_samples/pipeline_a11y_scenarios.yaml new file mode 100644 index 00000000000..60fc54a02f5 --- /dev/null +++ b/packages/cypress/resources/pipelines_samples/pipeline_a11y_scenarios.yaml @@ -0,0 +1,213 @@ +# PIPELINE DEFINITION +# Name: pipeline-a11y-scenarios +# Description: Success, parallel work, nested sub-steps, an artifact, and an intentional failure. +components: + comp-fail-on-purpose: + executorLabel: exec-fail-on-purpose + comp-multi-substep-phase: + dag: + tasks: + report-step: + cachingOptions: + enableCache: true + componentRef: + name: comp-report-step + inputs: + parameters: + message: + runtimeValue: + constant: 'Nested sub-step one: prepare' + taskInfo: + name: Prepare inputs + report-step-2: + cachingOptions: + enableCache: true + componentRef: + name: comp-report-step-2 + dependentTasks: + - report-step + inputs: + parameters: + message: + runtimeValue: + constant: 'Nested sub-step two: transform' + taskInfo: + name: Transform inputs + report-step-3: + cachingOptions: + enableCache: true + componentRef: + name: comp-report-step-3 + dependentTasks: + - report-step-2 + inputs: + parameters: + message: + runtimeValue: + constant: 'Nested sub-step three: verify' + taskInfo: + name: Verify transformation + comp-parallel-check: + executorLabel: exec-parallel-check + comp-report-step: + executorLabel: exec-report-step + inputDefinitions: + parameters: + message: + parameterType: STRING + comp-report-step-2: + executorLabel: exec-report-step-2 + inputDefinitions: + parameters: + message: + parameterType: STRING + comp-report-step-3: + executorLabel: exec-report-step-3 + inputDefinitions: + parameters: + message: + parameterType: STRING + comp-skipped-successor: + executorLabel: exec-skipped-successor + comp-start-step: + executorLabel: exec-start-step + comp-write-artifact: + executorLabel: exec-write-artifact + outputDefinitions: + artifacts: + output: + artifactType: + schemaTitle: system.Artifact + schemaVersion: 0.0.1 +deploymentSpec: + executors: + exec-fail-on-purpose: + container: + args: + - echo "Intentional failure for accessibility testing" >&2; exit 42 + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-parallel-check: + container: + args: + - 'printf "Step: Parallel check\n"' + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-report-step: + container: + args: + - 'printf "Step: %s\n" "$0"' + - '{{$.inputs.parameters[''message'']}}' + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-report-step-2: + container: + args: + - 'printf "Step: %s\n" "$0"' + - '{{$.inputs.parameters[''message'']}}' + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-report-step-3: + container: + args: + - 'printf "Step: %s\n" "$0"' + - '{{$.inputs.parameters[''message'']}}' + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-skipped-successor: + container: + args: + - printf "This step should be skipped after the intentional failure\n" + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-start-step: + container: + args: + - 'printf "Step: Start\n"' + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 + exec-write-artifact: + container: + args: + - printf "artifact for accessibility testing\n" > "$0" + - '{{$.outputs.artifacts[''output''].path}}' + command: + - sh + - -c + image: registry.access.redhat.com/ubi9/ubi-minimal:9.6 +pipelineInfo: + description: Success, parallel work, nested sub-steps, an artifact, and an intentional + failure. + name: pipeline-a11y-scenarios +root: + dag: + tasks: + fail-on-purpose: + cachingOptions: + enableCache: true + componentRef: + name: comp-fail-on-purpose + dependentTasks: + - parallel-check + - write-artifact + taskInfo: + name: Intentional failure + multi-substep-phase: + cachingOptions: + enableCache: true + componentRef: + name: comp-multi-substep-phase + dependentTasks: + - start-step + taskInfo: + name: Three sub-step group + parallel-check: + cachingOptions: + enableCache: true + componentRef: + name: comp-parallel-check + dependentTasks: + - start-step + taskInfo: + name: Parallel check + skipped-successor: + cachingOptions: + enableCache: true + componentRef: + name: comp-skipped-successor + dependentTasks: + - fail-on-purpose + taskInfo: + name: Skipped after failure + start-step: + cachingOptions: + enableCache: true + componentRef: + name: comp-start-step + taskInfo: + name: Start + write-artifact: + cachingOptions: + enableCache: true + componentRef: + name: comp-write-artifact + dependentTasks: + - multi-substep-phase + taskInfo: + name: Produce artifact +schemaVersion: 2.1.0 +sdkVersion: kfp-2.14.2