diff --git a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte index 5d803eb203..5cb29ca80a 100644 --- a/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte +++ b/src/lib/components/lines-and-dots/svg/history-graph-row-visual.svelte @@ -1,4 +1,5 @@ - + {#if connectLine} import Icon from '$lib/holocene/icon/icon.svelte'; + import { translate } from '$lib/i18n/translate'; import type { WorkflowExecution } from '$lib/types/workflows'; import { isWorkflowDelayed } from '$lib/utilities/delayed-workflows'; + import { getWorkflowStatusLabel } from '$lib/utilities/get-status-label'; import { TimelineConfig } from '../constants'; @@ -20,9 +22,22 @@ const start = $derived(gutter); const end = $derived(start + length - 2 * gutter); + + const accessibleName = $derived( + translate('workflows.row-accessible-name', { + workflowId: workflow.id, + status: getWorkflowStatusLabel(workflow.status), + }), + ); - + = { - Running: translate('workflows.running'), - TimedOut: translate('workflows.timed-out'), - Completed: translate('workflows.completed'), - Failed: translate('workflows.failed'), - ContinuedAsNew: translate('workflows.continued-as-new'), - Canceled: translate('workflows.canceled'), - Terminated: translate('workflows.terminated'), - Paused: translate('workflows.paused'), - Scheduled: translate('events.event-classification.scheduled'), - Started: translate('events.event-classification.started'), - Unspecified: translate('events.event-classification.unspecified'), - Open: translate('events.event-classification.open'), - New: translate('events.event-classification.new'), - Initiated: translate('events.event-classification.initiated'), - Fired: translate('events.event-classification.fired'), - CancelRequested: translate('events.event-classification.cancelrequested'), - Signaled: translate('events.event-classification.signaled'), - Pending: translate('events.event-classification.pending'), - Retrying: translate('events.event-classification.retrying'), - }; - const workflowStatus = cva( [ 'flex items-center rounded-sm px-1 py-0.5 h-5 whitespace-nowrap text-black gap-1 font-medium', @@ -135,7 +104,7 @@ {count.toLocaleString()} {/if} - {label[status]} + {getStatusLabel(status)} {#if status === 'Running' && !delayed && !taskFailure} {/if} diff --git a/src/lib/i18n/locales/en/events.ts b/src/lib/i18n/locales/en/events.ts index 72230aadc6..66eea86b68 100644 --- a/src/lib/i18n/locales/en/events.ts +++ b/src/lib/i18n/locales/en/events.ts @@ -66,6 +66,7 @@ export const Strings = { 'event-history-import-error': 'Could not create event history from JSON', 'event-history-load-error': 'Could not parse JSON', 'event-classification-label': 'Event Classification', + 'row-accessible-name': 'Event {{eventType}}: {{classification}}', 'event-classification': { unspecified: 'Unspecified', scheduled: 'Scheduled', diff --git a/src/lib/i18n/locales/en/workflows.ts b/src/lib/i18n/locales/en/workflows.ts index 8b427a67cd..a12b9a419e 100644 --- a/src/lib/i18n/locales/en/workflows.ts +++ b/src/lib/i18n/locales/en/workflows.ts @@ -41,6 +41,7 @@ export const Strings = { 'configure-headers-description': 'Add (<1>), re-arrange (<2>), and remove (<3>), {{type}} to personalize the {{title}} Table.', 'all-statuses': 'All Statuses', + 'row-accessible-name': 'Workflow {{workflowId}}: {{status}}', running: 'Running', 'timed-out': 'Timed Out', completed: 'Completed', diff --git a/src/lib/utilities/get-status-label.test.ts b/src/lib/utilities/get-status-label.test.ts new file mode 100644 index 0000000000..31c09024bd --- /dev/null +++ b/src/lib/utilities/get-status-label.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + getEventClassificationLabel, + getStatusLabel, + getWorkflowStatusLabel, + type Status, +} from './get-status-label'; + +describe('getWorkflowStatusLabel', () => { + it('translates workflow / schedule statuses', () => { + expect(getWorkflowStatusLabel('Running')).toBe('Running'); + expect(getWorkflowStatusLabel('TimedOut')).toBe('Timed Out'); + expect(getWorkflowStatusLabel('ContinuedAsNew')).toBe('Continued as New'); + expect(getWorkflowStatusLabel('Paused')).toBe('Paused'); + }); + + it('falls back to "Unknown" for null / undefined / event-only names', () => { + expect(getWorkflowStatusLabel(undefined)).toBe('Unknown'); + expect(getWorkflowStatusLabel(null)).toBe('Unknown'); + // Event-only classification is not part of the workflow domain. + expect(getWorkflowStatusLabel('Signaled' as never)).toBe('Unknown'); + }); +}); + +describe('getEventClassificationLabel', () => { + it('translates event classifications, pending and retrying', () => { + expect(getEventClassificationLabel('Scheduled')).toBe('Scheduled'); + expect(getEventClassificationLabel('CancelRequested')).toBe( + 'Cancel Requested', + ); + expect(getEventClassificationLabel('Pending')).toBe('Pending'); + expect(getEventClassificationLabel('Retrying')).toBe('Retrying'); + }); + + it('resolves overlapping names via the event namespace', () => { + // Same English today, but routed through events.* — not workflows.*. + expect(getEventClassificationLabel('Completed')).toBe('Completed'); + expect(getEventClassificationLabel('Failed')).toBe('Failed'); + }); + + it('falls back to "Unknown" for undefined / unmapped values', () => { + expect(getEventClassificationLabel(undefined)).toBe('Unknown'); + }); +}); + +describe('getStatusLabel (combined, workflow-domain precedence)', () => { + it('labels both workflow statuses and event classifications', () => { + expect(getStatusLabel('Completed')).toBe('Completed'); + expect(getStatusLabel('Signaled')).toBe('Signaled'); + expect(getStatusLabel('Pending')).toBe('Pending'); + }); + + it('falls back to "Unknown" for null / undefined / unmapped values', () => { + expect(getStatusLabel(undefined)).toBe('Unknown'); + expect(getStatusLabel(null)).toBe('Unknown'); + expect(getStatusLabel('NotAStatus' as Status)).toBe('Unknown'); + }); +}); diff --git a/src/lib/utilities/get-status-label.ts b/src/lib/utilities/get-status-label.ts new file mode 100644 index 0000000000..9dd13f8d5a --- /dev/null +++ b/src/lib/utilities/get-status-label.ts @@ -0,0 +1,90 @@ +import type { I18nKey } from '$lib/i18n'; +import { translate } from '$lib/i18n/translate'; +import type { EventClassification } from '$lib/models/event-history/get-event-classification'; +import type { ScheduleStatus } from '$lib/types/schedule'; +import type { WorkflowStatus } from '$lib/types/workflows'; + +export type Status = + | WorkflowStatus + | ScheduleStatus + | EventClassification + | 'Pending' + | 'Retrying'; + +type WorkflowDomainStatus = NonNullable; +type EventDomainStatus = EventClassification | 'Pending' | 'Retrying'; + +// Workflow / schedule statuses resolve to the workflows.* namespace. +const workflowStatusLabelKeys: Record = { + Running: 'workflows.running', + TimedOut: 'workflows.timed-out', + Completed: 'workflows.completed', + Failed: 'workflows.failed', + ContinuedAsNew: 'workflows.continued-as-new', + Canceled: 'workflows.canceled', + Terminated: 'workflows.terminated', + Paused: 'workflows.paused', +}; + +// Event classifications resolve to the events.event-classification.* namespace, +// including names (Running, Completed, …) that also exist as workflow statuses. +const eventClassificationLabelKeys: Record = { + Unspecified: 'events.event-classification.unspecified', + Scheduled: 'events.event-classification.scheduled', + Open: 'events.event-classification.open', + New: 'events.event-classification.new', + Started: 'events.event-classification.started', + Initiated: 'events.event-classification.initiated', + Running: 'events.event-classification.running', + Completed: 'events.event-classification.completed', + Fired: 'events.event-classification.fired', + CancelRequested: 'events.event-classification.cancelrequested', + TimedOut: 'events.event-classification.timedout', + Signaled: 'events.event-classification.signaled', + Canceled: 'events.event-classification.canceled', + Failed: 'events.event-classification.failed', + Terminated: 'events.event-classification.terminated', + Pending: 'events.event-classification.pending', + Retrying: 'events.event-classification.retrying', +}; + +const isWorkflowStatus = (status: string): status is WorkflowDomainStatus => + status in workflowStatusLabelKeys; + +const isEventClassification = (status: string): status is EventDomainStatus => + status in eventClassificationLabelKeys; + +/** Label a workflow or schedule status (workflows.* namespace). */ +export const getWorkflowStatusLabel = ( + status: WorkflowStatus | ScheduleStatus | undefined, +): string => + status && isWorkflowStatus(status) + ? translate(workflowStatusLabelKeys[status]) + : translate('common.unknown'); + +/** Label an event classification (events.event-classification.* namespace). */ +export const getEventClassificationLabel = ( + classification: EventDomainStatus | undefined, +): string => + classification && isEventClassification(classification) + ? translate(eventClassificationLabelKeys[classification]) + : translate('common.unknown'); + +/** + * Polymorphic resolver for WorkflowStatus.svelte, which renders one badge for + * any status — a workflow execution status, a schedule status, or an event + * classification — and cannot tell the domain from the value alone. Names + * shared by both domains (Running, Completed, Failed, Canceled, Terminated, + * TimedOut) resolve to the workflow label, preserving the component's + * historical behavior. Domain-specific callers should prefer + * getWorkflowStatusLabel / getEventClassificationLabel instead. + */ +export const getStatusLabel = (status: Status | undefined): string => { + if (status && isWorkflowStatus(status)) { + return translate(workflowStatusLabelKeys[status]); + } + if (status && isEventClassification(status)) { + return translate(eventClassificationLabelKeys[status]); + } + return translate('common.unknown'); +}; diff --git a/tests/integration/timeline-graph-accessible-names.spec.ts b/tests/integration/timeline-graph-accessible-names.spec.ts new file mode 100644 index 0000000000..ff11d6c62d --- /dev/null +++ b/tests/integration/timeline-graph-accessible-names.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; + +import { mockWorkflowApis } from '~/test-utilities/mock-apis'; +import { mockWorkflow } from '~/test-utilities/mocks/workflow'; + +const { workflowId, runId } = mockWorkflow.workflowExecutionInfo.execution; +const timelineUrl = `/namespaces/default/workflows/${workflowId}/${runId}/timeline`; + +// The timeline graph renders SVG `` nodes (workflow-row and +// timeline-graph-row). Without an aria-label these announce as a bare "button" +// to screen readers; these assertions fail if that label regresses. +test.describe('Timeline graph node accessible names', () => { + test.beforeEach(async ({ page }) => { + await mockWorkflowApis(page); + await page.goto(timelineUrl); + }); + + test('workflow node announces its id and status', async ({ page }) => { + await expect( + page.getByRole('button', { name: `Workflow ${workflowId}: Running` }), + ).toBeVisible(); + }); + + test('event nodes announce their type and classification', async ({ + page, + }) => { + await expect( + page.getByRole('button', { name: 'Event LongActivity: Scheduled' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Event customSignal: Signaled' }), + ).toBeVisible(); + }); +});