From 62d25a293c2410ae0d32b7821bebcc7affa3caa6 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 11 May 2026 11:07:05 +0000 Subject: [PATCH 01/10] fix: ctrl+enter should run primary action --- .../components/Header.tsx | 18 +++++++++++++ .../components/WorkflowEditor.tsx | 27 ------------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 3924c8167a..495d1b5181 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -326,6 +326,24 @@ export function Header({ } }, [provider, projectId, workflowId, isNewWorkflow]); + useKeyboardShortcut( + 'Control+Enter, Meta+Enter', + () => { + void handleRunClick(); + }, + 0, + { + enabled: + canRun && + !isRunPanelOpen && + !isIDEOpen && + !isNewWorkflow && + !!firstTriggerId && + !!projectId && + !!workflowId, + } + ); + useKeyboardShortcut( 'Control+s, Meta+s', () => { diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index 1051ce521a..0376b21d87 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -431,33 +431,6 @@ export function WorkflowEditor({ collapseCreateWorkflowPanel(); }; - useKeyboardShortcut( - 'Control+Enter, Meta+Enter', - () => { - if (isRunPanelOpen) { - return; - } - - if (currentNode.type === 'job' && currentNode.node) { - openRunPanel({ jobId: currentNode.node.id }); - } else if (currentNode.type === 'trigger' && currentNode.node) { - openRunPanel({ triggerId: currentNode.node.id }); - } else { - const firstTrigger = workflow.triggers[0]; - if (firstTrigger?.id) { - openRunPanel({ - triggerId: firstTrigger.id, - entryPoint: 'custom-input', - }); - } - } - }, - 0, - { - enabled: !isIDEOpen && !isRunPanelOpen, - } - ); - /** * Keyboard shortcuts for new workflow creation panels. * From 6f933891e4db362afa8fb6e016cb4c6d87f8868a Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 11 May 2026 15:07:00 +0000 Subject: [PATCH 02/10] tests: move tests to header --- .../components/Header.keyboard.test.tsx | 241 +++++++++ .../WorkflowEditor.keyboard.test.tsx | 480 ------------------ 2 files changed, 241 insertions(+), 480 deletions(-) delete mode 100644 assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx diff --git a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx index 3093c5286a..23ef45289f 100644 --- a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx +++ b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx @@ -27,6 +27,7 @@ import { createMockURLState, getURLStateMockValue, } from '../__helpers__/urlStateMocks'; +import { createWorkflowYDoc } from '../__helpers__/workflowFactory'; import { createMinimalWorkflowYDoc } from '../__helpers__/workflowStoreHelpers'; // ============================================================================= @@ -50,10 +51,53 @@ vi.mock('../../../js/collaborative-editor/components/Tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); +// Mock dataclipApi so submitManualRun can be spied on +const mockSubmitManualRun = vi.fn(); +vi.mock('../../../js/collaborative-editor/api/dataclips', () => ({ + submitManualRun: (...args: unknown[]) => mockSubmitManualRun(...args), + searchDataclips: vi.fn(() => + Promise.resolve({ + data: [], + next_cron_run_dataclip_id: null, + can_edit_dataclip: true, + }) + ), +})); + // ============================================================================= // TEST HELPERS // ============================================================================= +/** + * Creates a Y.Doc with workflow metadata AND a trigger so firstTriggerId is set. + * Used by the Cmd+Enter tests which require canRun + firstTriggerId to be truthy. + */ +function createWorkflowYDocWithTrigger( + lockVersion: number | null = 1 +): ReturnType & { triggerId: string } { + const triggerId = 'trigger-test-1'; + const ydoc = createWorkflowYDoc({ + triggers: { + [triggerId]: { id: triggerId, type: 'webhook', enabled: true }, + }, + }); + + // Merge workflow metadata into the doc (createWorkflowYDoc doesn't set it) + const workflowMap = ydoc.getMap('workflow'); + workflowMap.set('id', 'test-workflow-123'); + workflowMap.set('name', 'Test Workflow'); + workflowMap.set('lock_version', lockVersion); + workflowMap.set('deleted_at', null); + workflowMap.set('concurrency', null); + workflowMap.set('enable_job_logs', false); + + // createWorkflowYDoc doesn't initialise positions / errors maps + ydoc.getMap('positions'); + ydoc.getMap('errors'); + + return Object.assign(ydoc, { triggerId }); +} + interface WrapperOptions { permissions?: { can_edit_workflow: boolean; can_run_workflow: boolean }; latestSnapshotLockVersion?: number; @@ -965,3 +1009,200 @@ describe('Header - Guard Condition Interactions', () => { cleanup(); }); }); + +// ============================================================================= +// CMD+ENTER / CTRL+ENTER – SUBMIT MANUAL RUN +// ============================================================================= + +describe('Header - Submit Manual Run (Cmd+Enter / Ctrl+Enter)', () => { + beforeEach(() => { + urlState.reset(); + vi.clearAllMocks(); + // Default: submitManualRun resolves successfully + mockSubmitManualRun.mockResolvedValue({ data: { run_id: 'run-123' } }); + }); + + afterEach(async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + /** + * Sets up stores with a Y.Doc that already contains a trigger so that + * `firstTriggerId` is defined and the shortcut guard passes. + */ + async function createRunSetup( + options: WrapperOptions & { + isRunPanelOpen?: boolean; + isIDEOpen?: boolean; + } = {} + ) { + const { + isRunPanelOpen = false, + isIDEOpen = false, + ...wrapperOptions + } = options; + + const ydoc = createWorkflowYDocWithTrigger( + wrapperOptions.workflowLockVersion ?? 1 + ); + + const { stores, sessionStore, cleanup, emitSessionContext } = + await simulateStoreProviderWithConnection( + 'test:room', + { id: 'user-1', name: 'Test User', color: '#ff0000' }, + { + workflowYDoc: ydoc, + sessionContext: { + permissions: wrapperOptions.permissions ?? { + can_edit_workflow: true, + can_run_workflow: true, + }, + latest_snapshot_lock_version: + wrapperOptions.latestSnapshotLockVersion ?? 1, + }, + emitSessionContext: true, + } + ); + + // Manually emit sync so isSynced becomes true + const provider = sessionStore.getProvider(); + if (provider) { + (provider as any).emit('sync', [true]); + } + await new Promise(resolve => setTimeout(resolve, 150)); + + vi.spyOn(stores.workflowStore, 'saveWorkflow').mockResolvedValue(null); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); + + async function renderAndWait() { + const result = render( +
+ {[Breadcrumb]} +
, + { wrapper } + ); + + await act(async () => { + emitSessionContext!(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + expect(screen.getByTestId('save-workflow-button')).toBeInTheDocument(); + }); + + return result; + } + + return { wrapper, stores, emitSessionContext, cleanup, renderAndWait }; + } + + test('Cmd+Enter submits run when canRun is true (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await waitFor(() => expect(mockSubmitManualRun).toHaveBeenCalledTimes(1)); + expect(mockSubmitManualRun).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + projectId: 'project-1', + triggerId: 'trigger-test-1', + }) + ); + + unmount(); + cleanup(); + }); + + test('Ctrl+Enter submits run when canRun is true (Windows)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Control>}{Enter}{/Control}'); + + await waitFor(() => expect(mockSubmitManualRun).toHaveBeenCalledTimes(1)); + + unmount(); + cleanup(); + }); + + test('Cmd+Enter does NOT submit run when no run permission', async () => { + const user = userEvent.setup(); + // canRun requires hasEditPermission OR hasRunPermission — both must be false + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: false, can_run_workflow: false }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+Enter does NOT submit run when run panel is open', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + isRunPanelOpen: true, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+Enter does NOT submit run when IDE is open', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + isIDEOpen: true, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); +}); diff --git a/assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx b/assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx deleted file mode 100644 index 6c69db1f41..0000000000 --- a/assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx +++ /dev/null @@ -1,480 +0,0 @@ -/** - * WorkflowEditor Keyboard Shortcuts Tests - * - * Tests keyboard shortcut behavior in WorkflowEditor using a library-agnostic - * approach that tests user-facing behavior rather than implementation details. - * Tests survive library migrations and document expected user behavior. - * - * Shortcuts tested: - * - Mod+Enter: Open run panel for selected node or first trigger - * - * Note: Cmd+E / Ctrl+E IDE shortcut tests are in CollaborativeEditor.keyboard.test.tsx - * since the IDE is rendered by CollaborativeEditor, not WorkflowEditor. - */ - -import { screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { WorkflowEditor } from '../../../js/collaborative-editor/components/WorkflowEditor'; -import type { Workflow } from '../../../js/collaborative-editor/types/workflow'; -import { - expectShortcutNotToFire, - keys, - renderWithKeyboard, -} from '../../keyboard-test-utils'; -import { - createMockURLState, - getURLStateMockValue, -} from '../__helpers__/urlStateMocks'; - -// Mock dependencies -vi.mock('../../../js/collaborative-editor/api/dataclips', () => ({ - searchDataclips: vi.fn(() => - Promise.resolve({ - data: [], - next_cron_run_dataclip_id: null, - can_edit_dataclip: true, - }) - ), -})); - -// Mock MonacoEditor -vi.mock('@monaco-editor/react', () => ({ - default: ({ value }: { value: string }) => ( -
{value}
- ), -})); - -// Mock CollaborativeWorkflowDiagram -vi.mock( - '../../../js/collaborative-editor/components/diagram/CollaborativeWorkflowDiagram', - () => ({ - CollaborativeWorkflowDiagram: () => ( -
Workflow Diagram
- ), - }) -); - -// Mock Inspector -vi.mock('../../../js/collaborative-editor/components/inspector', () => ({ - Inspector: () =>
Inspector
, -})); - -// Mock LeftPanel -vi.mock('../../../js/collaborative-editor/components/left-panel', () => ({ - LeftPanel: () =>
Left Panel
, -})); - -// Mock FullScreenIDE -vi.mock( - '../../../js/collaborative-editor/components/ide/FullScreenIDE', - () => ({ - FullScreenIDE: () => ( -
Full Screen IDE
- ), - }) -); - -// Mock ManualRunPanel -vi.mock('../../../js/collaborative-editor/components/ManualRunPanel', () => ({ - ManualRunPanel: ({ - jobId, - triggerId, - }: { - jobId?: string; - triggerId?: string; - }) => ( -
- ManualRunPanel -
- ), -})); - -// Create controllable mocks -const mockOpenRunPanel = vi.fn(); -const mockCloseRunPanel = vi.fn(); -const mockSelectNode = vi.fn(); -const urlState = createMockURLState(); - -// Mock useURLState -vi.mock('../../../js/react/lib/use-url-state', () => ({ - useURLState: () => getURLStateMockValue(urlState), -})); - -// Mock session context hooks -vi.mock('../../../js/collaborative-editor/hooks/useSessionContext', () => ({ - useIsNewWorkflow: () => false, - useProjectRepoConnection: () => undefined, - useProject: () => ({ - id: 'project-1', - name: 'Test Project', - }), - useVersions: () => [], - useVersionsLoading: () => false, - useVersionsError: () => null, - useRequestVersions: () => vi.fn(), -})); - -// Create mock workflow -const mockWorkflow: Workflow = { - id: 'workflow-1', - name: 'Test Workflow', - jobs: [ - { - id: 'job-1', - name: 'Job 1', - adaptor: '@openfn/language-http@latest', - body: 'fn(state => state)', - enabled: true, - project_credential_id: null, - keychain_credential_id: null, - }, - { - id: 'job-2', - name: 'Job 2', - adaptor: '@openfn/language-http@latest', - body: 'fn(state => state)', - enabled: true, - project_credential_id: null, - keychain_credential_id: null, - }, - ], - triggers: [ - { - id: 'trigger-1', - type: 'webhook', - enabled: true, - }, - { - id: 'trigger-2', - type: 'cron', - enabled: true, - }, - ], - edges: [], -}; - -// Mock UI hooks with controllable state -const mockIsRunPanelOpen = vi.fn(() => false); -const mockRunPanelContext = vi.fn(() => null); - -vi.mock('../../../js/collaborative-editor/hooks/useUI', () => ({ - useIsRunPanelOpen: () => mockIsRunPanelOpen(), - useRunPanelContext: () => mockRunPanelContext(), - useUICommands: () => ({ - openRunPanel: mockOpenRunPanel, - closeRunPanel: mockCloseRunPanel, - toggleCreateWorkflowPanel: vi.fn(), - openAIAssistantPanel: vi.fn(), - closeAIAssistantPanel: vi.fn(), - collapseCreateWorkflowPanel: vi.fn(), - expandCreateWorkflowPanel: vi.fn(), - selectTemplate: vi.fn(), - setTemplateSearchQuery: vi.fn(), - }), - useTemplatePanel: () => ({ - templates: [], - loading: false, - error: null, - searchQuery: '', - selectedTemplate: null, - }), - useIsCreateWorkflowPanelCollapsed: () => true, - useIsAIAssistantPanelOpen: () => false, -})); - -// Mock workflow hooks with controllable node selection -let currentNode: { - type: 'job' | 'trigger' | 'edge' | null; - node: any; -} = { - type: null, - node: null, -}; - -vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({ - useNodeSelection: () => ({ - currentNode, - selectNode: mockSelectNode, - }), - useWorkflowStoreContext: () => ({ - validateWorkflowName: vi.fn(), - importWorkflow: vi.fn(), - }), - useWorkflowActions: () => ({ - saveWorkflow: vi.fn(), - }), - useWorkflowState: (selector: any) => { - const state = { - workflow: mockWorkflow, - jobs: mockWorkflow.jobs, - triggers: mockWorkflow.triggers, - edges: mockWorkflow.edges, - positions: {}, - }; - return typeof selector === 'function' ? selector(state) : state; - }, - useCanRun: () => ({ - canRun: true, - tooltipMessage: '', - }), -})); - -describe('WorkflowEditor keyboard shortcuts', () => { - beforeEach(() => { - vi.clearAllMocks(); - urlState.reset(); - - // Reset state - mockIsRunPanelOpen.mockReturnValue(false); - mockRunPanelContext.mockReturnValue(null); - currentNode = { type: null, node: null }; - }); - - describe('Mod+Enter - Open Run Panel', () => { - test('opens run panel for selected job with Cmd+Enter on Mac', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-1' }); - }); - }); - - test('opens run panel for selected job with Ctrl+Enter on Windows/Linux', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('ctrl'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-1' }); - }); - }); - - test('opens run panel for selected trigger', async () => { - currentNode = { - type: 'trigger', - node: mockWorkflow.triggers[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ - triggerId: 'trigger-1', - }); - }); - }); - - test('falls back to first trigger when nothing selected', async () => { - currentNode = { type: null, node: null }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ - triggerId: 'trigger-1', - entryPoint: 'custom-input', - }); - }); - }); - - test('delegates to ManualRunPanel when run panel already open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - // Run panel is already open - mockIsRunPanelOpen.mockReturnValue(true); - mockRunPanelContext.mockReturnValue({ jobId: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('manual-run-panel')).toBeInTheDocument(); - }); - - container.focus(); - - // Try to open run panel again - should not call openRunPanel - // (ManualRunPanel's shortcut handler will execute instead) - await expectShortcutNotToFire(keys.run('cmd'), mockOpenRunPanel, user); - }); - - test('does not trigger when IDE is open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - // IDE is open - urlState.setParams({ panel: 'editor', job: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await expectShortcutNotToFire(keys.run('cmd'), mockOpenRunPanel, user); - }); - - test('works in form fields (enableOnFormTags)', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - // Create and focus a textarea - const textarea = document.createElement('textarea'); - container.appendChild(textarea); - textarea.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-1' }); - }); - }); - }); - - describe('guard conditions', () => { - test('Mod+Enter disabled when IDE open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - urlState.setParams({ panel: 'editor', job: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await expectShortcutNotToFire(keys.run('ctrl'), mockOpenRunPanel, user); - }); - - test('Mod+Enter disabled when run panel already open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - mockIsRunPanelOpen.mockReturnValue(true); - mockRunPanelContext.mockReturnValue({ jobId: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('manual-run-panel')).toBeInTheDocument(); - }); - - container.focus(); - - await expectShortcutNotToFire(keys.run('ctrl'), mockOpenRunPanel, user); - }); - }); - - describe('behavior with different node selections', () => { - test('Mod+Enter opens run panel for job selection', async () => { - // Test with job selection - currentNode = { - type: 'job', - node: mockWorkflow.jobs[1], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('ctrl'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-2' }); - }); - }); - - test('Mod+Enter opens run panel for trigger selection', async () => { - // Test with trigger selection - currentNode = { - type: 'trigger', - node: mockWorkflow.triggers[1], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('ctrl'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ - triggerId: 'trigger-2', - }); - }); - }); - }); -}); From 0a8ee68509045230085260a9e3c328ad41f2fe5a Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 11 May 2026 15:50:38 +0000 Subject: [PATCH 03/10] fix: retry when run is loaded --- .../components/Header.tsx | 99 ++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 495d1b5181..5f805cffcc 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -7,6 +7,7 @@ import { buildClassicalEditorUrl } from '../../utils/editorUrlConversion'; import * as dataclipApi from '../api/dataclips'; import { StoreContext } from '../contexts/StoreProvider'; import { channelRequest } from '../hooks/useChannel'; +import { getCsrfToken } from '../lib/csrf'; import { useActiveRun } from '../hooks/useHistory'; import { useSession } from '../hooks/useSession'; import { @@ -42,6 +43,7 @@ import { EmailVerificationBanner } from './EmailVerificationBanner'; import { GitHubSyncModal } from './GitHubSyncModal'; import { Switch } from './inputs/Switch'; import { NewRunButton } from './NewRunButton'; +import { RunRetryButton } from './RunRetryButton'; import { ReadOnlyWarning } from './ReadOnlyWarning'; import { ShortcutKeys } from './ShortcutKeys'; import { Tooltip } from '../../components/Tooltip'; @@ -236,6 +238,12 @@ export function Header({ const [isSubmitting, setIsSubmitting] = useState(false); const activeRun = useActiveRun(); const runIsProcessing = activeRun ? !isFinalState(activeRun.state) : false; + const followedRunId = params.run ?? null; + const isRetryable = + !!followedRunId && + !!activeRun && + isFinalState(activeRun.state) && + !!activeRun.steps?.length; // Check GitHub sync limit const githubSyncLimit = limits.github_sync ?? { @@ -296,6 +304,57 @@ export function Header({ updateSearchParams, ]); + const handleRetryClick = useCallback(async () => { + if (!followedRunId || !activeRun?.steps?.length || !projectId) return; + + setIsSubmitting(true); + try { + await saveWorkflow({ silent: true }); + + const firstStep = activeRun.steps[0]; + const retryUrl = `/projects/${projectId}/runs/${followedRunId}/retry`; + const csrfToken = getCsrfToken(); + const response = await fetch(retryUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken || '', + }, + body: JSON.stringify({ step_id: firstStep.id }), + }); + + if (!response.ok) { + const error = (await response.json()) as { error?: string }; + throw new Error(error.error || 'Failed to retry run'); + } + + const result = (await response.json()) as { data: { run_id: string } }; + + notifications.success({ + title: 'Retry started', + description: 'Saved latest changes and re-running with previous input', + }); + + if (getLimits) void getLimits('new_run'); + updateSearchParams({ run: result.data.run_id }); + } catch (error) { + notifications.alert({ + title: 'Retry failed', + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsSubmitting(false); + } + }, [ + followedRunId, + activeRun, + projectId, + saveWorkflow, + getLimits, + updateSearchParams, + ]); + const handleRunWithCustomInputClick = useCallback(() => { if (firstTriggerId) { selectNode(firstTriggerId); @@ -329,7 +388,11 @@ export function Header({ useKeyboardShortcut( 'Control+Enter, Meta+Enter', () => { - void handleRunClick(); + if (isRetryable) { + void handleRetryClick(); + } else { + void handleRunClick(); + } }, 0, { @@ -338,9 +401,9 @@ export function Header({ !isRunPanelOpen && !isIDEOpen && !isNewWorkflow && - !!firstTriggerId && !!projectId && - !!workflowId, + !!workflowId && + (isRetryable || !!firstTriggerId), } ); @@ -456,14 +519,28 @@ export function Header({
- {projectId && workflowId && firstTriggerId && !isNewWorkflow && ( - - )} + {projectId && + workflowId && + !isNewWorkflow && + (isRetryable ? ( + + ) : firstTriggerId ? ( + + ) : null)} Date: Mon, 11 May 2026 16:26:42 +0000 Subject: [PATCH 04/10] fix: keyboard shortcuts around retry --- .../components/Header.tsx | 22 +++++++++++++++++++ .../components/NewRunButton.tsx | 3 +++ 2 files changed, 25 insertions(+) diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 5f805cffcc..4915316ea8 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -407,6 +407,28 @@ export function Header({ } ); + useKeyboardShortcut( + 'Control+Shift+Enter, Meta+Shift+Enter', + () => { + if (isRetryable) { + void handleRunClick(); + } else { + handleRunWithCustomInputClick(); + } + }, + 0, + { + enabled: + canRun && + !isRunPanelOpen && + !isIDEOpen && + !isNewWorkflow && + !!projectId && + !!workflowId && + (isRetryable || !!firstTriggerId), + } + ); + useKeyboardShortcut( 'Control+s, Meta+s', () => { diff --git a/assets/js/collaborative-editor/components/NewRunButton.tsx b/assets/js/collaborative-editor/components/NewRunButton.tsx index 349f75d014..32fb782ffd 100644 --- a/assets/js/collaborative-editor/components/NewRunButton.tsx +++ b/assets/js/collaborative-editor/components/NewRunButton.tsx @@ -123,6 +123,9 @@ export function NewRunButton({ > Run with custom input + + + From e610dea5a52cecc1edd1b6cbbfb77f4462a8f128 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 11 May 2026 16:54:29 +0000 Subject: [PATCH 05/10] chore: update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3014d74af4..04f69d2f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ and this project adheres to ### Fixed +- `Cmd/Ctrl+Enter` now runs the workflow directly; `Cmd/Ctrl+Shift+Enter` opens + "run with custom input". When a run is loaded, the primary action switches to + retry and the secondary to new work order. + [#4736](https://github.com/OpenFn/lightning/issues/4736) + - `mix lightning.install_runtime` no longer reports success when Rambo's binary fails to start; both `Rambo.run/2` calls now raise with the underlying reason. [#4735](https://github.com/OpenFn/lightning/pull/4735) From 88bdc406fe0004b1c8701ddebef26eb4da09285c Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Tue, 12 May 2026 07:30:08 +0200 Subject: [PATCH 06/10] tweaks --- CHANGELOG.md | 5 +-- .../components/Header.tsx | 42 ++++++------------- .../components/NewRunButton.tsx | 26 ++++++------ 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f69d2f3e..d7cead72a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,8 @@ and this project adheres to ### Fixed - `Cmd/Ctrl+Enter` now runs the workflow directly; `Cmd/Ctrl+Shift+Enter` opens - "run with custom input". When a run is loaded, the primary action switches to - retry and the secondary to new work order. - [#4736](https://github.com/OpenFn/lightning/issues/4736) + "run with custom input". When a retryable run is loaded, the primary action + switches to retry. [#4736](https://github.com/OpenFn/lightning/issues/4736) - `mix lightning.install_runtime` no longer reports success when Rambo's binary fails to start; both `Rambo.run/2` calls now raise with the underlying reason. diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 4915316ea8..78dc241a1e 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -43,7 +43,6 @@ import { EmailVerificationBanner } from './EmailVerificationBanner'; import { GitHubSyncModal } from './GitHubSyncModal'; import { Switch } from './inputs/Switch'; import { NewRunButton } from './NewRunButton'; -import { RunRetryButton } from './RunRetryButton'; import { ReadOnlyWarning } from './ReadOnlyWarning'; import { ShortcutKeys } from './ShortcutKeys'; import { Tooltip } from '../../components/Tooltip'; @@ -410,11 +409,7 @@ export function Header({ useKeyboardShortcut( 'Control+Shift+Enter, Meta+Shift+Enter', () => { - if (isRetryable) { - void handleRunClick(); - } else { - handleRunWithCustomInputClick(); - } + handleRunWithCustomInputClick(); }, 0, { @@ -425,7 +420,7 @@ export function Header({ !isNewWorkflow && !!projectId && !!workflowId && - (isRetryable || !!firstTriggerId), + !!firstTriggerId, } ); @@ -541,28 +536,17 @@ export function Header({
- {projectId && - workflowId && - !isNewWorkflow && - (isRetryable ? ( - - ) : firstTriggerId ? ( - - ) : null)} + {projectId && workflowId && firstTriggerId && !isNewWorkflow && ( + { + void (isRetryable ? handleRetryClick() : handleRunClick()); + }} + onRunWithCustomInputClick={handleRunWithCustomInputClick} + disabled={!canRun || isRunPanelOpen || isIDEOpen} + isRunning={isSubmitting || runIsProcessing} + text={isRetryable ? 'Run (retry)' : 'Run'} + /> + )} ) : ( - + ); const splitButtonClasses = @@ -114,19 +114,21 @@ export function NewRunButton({ data-leave:duration-75 data-leave:ease-in" > - + > + + Run with custom input + + From 855945abbd7ca882207537f2f76b3075ae41cf5f Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Tue, 12 May 2026 07:39:12 +0200 Subject: [PATCH 07/10] fix tooltips, sticky dropdown --- .../components/Header.tsx | 2 +- .../components/NewRunButton.tsx | 31 ++++++++------ .../components/RunRetryButton.tsx | 41 ++++++++----------- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 78dc241a1e..07a3456ef1 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -544,7 +544,7 @@ export function Header({ onRunWithCustomInputClick={handleRunWithCustomInputClick} disabled={!canRun || isRunPanelOpen || isIDEOpen} isRunning={isSubmitting || runIsProcessing} - text={isRetryable ? 'Run (retry)' : 'Run'} + text={isRetryable ? 'Run (Retry)' : 'Run'} /> )} - } - side="bottom" - > - - + > + + Run with custom input + + + )} diff --git a/assets/js/collaborative-editor/components/RunRetryButton.tsx b/assets/js/collaborative-editor/components/RunRetryButton.tsx index ffdb1c3af0..b1ff6e286c 100644 --- a/assets/js/collaborative-editor/components/RunRetryButton.tsx +++ b/assets/js/collaborative-editor/components/RunRetryButton.tsx @@ -236,31 +236,24 @@ export function RunRetryButton({ {/* Chevron dropdown button - stays visible during processing for consistency */}
- !chevronDisabled && setIsDropdownOpen(!isDropdownOpen)} + disabled={chevronDisabled} + className={cn( + 'rounded-md text-sm font-semibold shadow-xs px-1 py-2', + 'h-full rounded-l-none', + 'focus-visible:outline-2 focus-visible:outline-offset-2', + chevronDisabled + ? [styles.submitting, 'cursor-not-allowed'] + : [styles.chevronBase, styles.focus] + )} + aria-expanded={isDropdownOpen} + aria-haspopup="true" > - - + Open options + + {/* Dropdown menu */} {isDropdownOpen && ( From f87c00cfcdb4c002d4caf075e1796a6babea7cb9 Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Tue, 12 May 2026 07:43:06 +0200 Subject: [PATCH 08/10] hero play solid --- .../test/collaborative-editor/components/NewRunButton.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/test/collaborative-editor/components/NewRunButton.test.tsx b/assets/test/collaborative-editor/components/NewRunButton.test.tsx index 6af877e1ee..8d36e3af83 100644 --- a/assets/test/collaborative-editor/components/NewRunButton.test.tsx +++ b/assets/test/collaborative-editor/components/NewRunButton.test.tsx @@ -64,7 +64,7 @@ describe('NewRunButton - Disabled Prop', () => { ); - const playIcon = container.querySelector('.hero-play'); + const playIcon = container.querySelector('.hero-play-solid'); expect(playIcon).toBeInTheDocument(); }); From 5097720473c4a2fba12658bc55666c15414c6218 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 12 May 2026 10:51:30 +0000 Subject: [PATCH 09/10] fix: prevent default key binding from @headlessui/react --- assets/js/collaborative-editor/components/NewRunButton.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/js/collaborative-editor/components/NewRunButton.tsx b/assets/js/collaborative-editor/components/NewRunButton.tsx index e26287dc69..e71650ccd7 100644 --- a/assets/js/collaborative-editor/components/NewRunButton.tsx +++ b/assets/js/collaborative-editor/components/NewRunButton.tsx @@ -112,6 +112,11 @@ export function NewRunButton({ transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-200 data-enter:ease-out data-leave:duration-75 data-leave:ease-in" + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + } + }} > {({ close }) => ( From 551b47603b5ebb03bc850db2339cdf301e4a1e62 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 12 May 2026 12:20:32 +0000 Subject: [PATCH 10/10] tests: update keyboard shortcut tests --- .../components/Header.keyboard.test.tsx | 388 ++++++++++++++---- 1 file changed, 303 insertions(+), 85 deletions(-) diff --git a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx index 23ef45289f..03eb8f07bf 100644 --- a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx +++ b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx @@ -17,6 +17,8 @@ import userEvent from '@testing-library/user-event'; import type React from 'react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { RunDetail } from '../../../js/collaborative-editor/types/history'; + import { Header } from '../../../js/collaborative-editor/components/Header'; import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; @@ -1010,6 +1012,93 @@ describe('Header - Guard Condition Interactions', () => { }); }); +// ============================================================================= +// SHARED RUN SETUP HELPER +// ============================================================================= + +/** + * Sets up stores with a Y.Doc that already contains a trigger so that + * `firstTriggerId` is defined and the shortcut guard passes. + */ +async function createRunSetup( + options: WrapperOptions & { + isRunPanelOpen?: boolean; + isIDEOpen?: boolean; + } = {} +) { + const { + isRunPanelOpen = false, + isIDEOpen = false, + ...wrapperOptions + } = options; + + const ydoc = createWorkflowYDocWithTrigger( + wrapperOptions.workflowLockVersion ?? 1 + ); + + const { stores, sessionStore, cleanup, emitSessionContext } = + await simulateStoreProviderWithConnection( + 'test:room', + { id: 'user-1', name: 'Test User', color: '#ff0000' }, + { + workflowYDoc: ydoc, + sessionContext: { + permissions: wrapperOptions.permissions ?? { + can_edit_workflow: true, + can_run_workflow: true, + }, + latest_snapshot_lock_version: + wrapperOptions.latestSnapshotLockVersion ?? 1, + }, + emitSessionContext: true, + } + ); + + // Manually emit sync so isSynced becomes true + const provider = sessionStore.getProvider(); + if (provider) { + (provider as any).emit('sync', [true]); + } + await new Promise(resolve => setTimeout(resolve, 150)); + + vi.spyOn(stores.workflowStore, 'saveWorkflow').mockResolvedValue(null); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + async function renderAndWait() { + const result = render( +
+ {[Breadcrumb]} +
, + { wrapper } + ); + + await act(async () => { + emitSessionContext!(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + expect(screen.getByTestId('save-workflow-button')).toBeInTheDocument(); + }); + + return result; + } + + return { wrapper, stores, emitSessionContext, cleanup, renderAndWait }; +} + // ============================================================================= // CMD+ENTER / CTRL+ENTER – SUBMIT MANUAL RUN // ============================================================================= @@ -1028,91 +1117,6 @@ describe('Header - Submit Manual Run (Cmd+Enter / Ctrl+Enter)', () => { }); }); - /** - * Sets up stores with a Y.Doc that already contains a trigger so that - * `firstTriggerId` is defined and the shortcut guard passes. - */ - async function createRunSetup( - options: WrapperOptions & { - isRunPanelOpen?: boolean; - isIDEOpen?: boolean; - } = {} - ) { - const { - isRunPanelOpen = false, - isIDEOpen = false, - ...wrapperOptions - } = options; - - const ydoc = createWorkflowYDocWithTrigger( - wrapperOptions.workflowLockVersion ?? 1 - ); - - const { stores, sessionStore, cleanup, emitSessionContext } = - await simulateStoreProviderWithConnection( - 'test:room', - { id: 'user-1', name: 'Test User', color: '#ff0000' }, - { - workflowYDoc: ydoc, - sessionContext: { - permissions: wrapperOptions.permissions ?? { - can_edit_workflow: true, - can_run_workflow: true, - }, - latest_snapshot_lock_version: - wrapperOptions.latestSnapshotLockVersion ?? 1, - }, - emitSessionContext: true, - } - ); - - // Manually emit sync so isSynced becomes true - const provider = sessionStore.getProvider(); - if (provider) { - (provider as any).emit('sync', [true]); - } - await new Promise(resolve => setTimeout(resolve, 150)); - - vi.spyOn(stores.workflowStore, 'saveWorkflow').mockResolvedValue(null); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - - {children} - - - - ); - - async function renderAndWait() { - const result = render( -
- {[Breadcrumb]} -
, - { wrapper } - ); - - await act(async () => { - emitSessionContext!(); - await new Promise(resolve => setTimeout(resolve, 150)); - }); - - await waitFor(() => { - expect(screen.getByTestId('save-workflow-button')).toBeInTheDocument(); - }); - - return result; - } - - return { wrapper, stores, emitSessionContext, cleanup, renderAndWait }; - } - test('Cmd+Enter submits run when canRun is true (Mac)', async () => { const user = userEvent.setup(); const { renderAndWait, cleanup } = await createRunSetup({ @@ -1206,3 +1210,217 @@ describe('Header - Submit Manual Run (Cmd+Enter / Ctrl+Enter)', () => { cleanup(); }); }); + +// ============================================================================= +// CMD+SHIFT+ENTER – RUN WITH CUSTOM INPUT (no run loaded) +// ============================================================================= + +describe('Header - Cmd+Shift+Enter (Run with custom input)', () => { + beforeEach(() => { + urlState.reset(); + vi.clearAllMocks(); + mockSubmitManualRun.mockResolvedValue({ data: { run_id: 'run-123' } }); + }); + + afterEach(async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + test('Cmd+Shift+Enter navigates to run panel with custom input when no run is loaded (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Shift>}{Enter}{/Shift}{/Meta}'); + + await waitFor(() => + expect(urlState.mockFns.updateSearchParams).toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ) + ); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Ctrl+Shift+Enter navigates to run panel with custom input when no run is loaded (Windows)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Control>}{Shift>}{Enter}{/Shift}{/Control}'); + + await waitFor(() => + expect(urlState.mockFns.updateSearchParams).toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ) + ); + + unmount(); + cleanup(); + }); + + test('Cmd+Shift+Enter does NOT navigate to run panel when run panel is already open', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + isRunPanelOpen: true, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Shift>}{Enter}{/Shift}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(urlState.mockFns.updateSearchParams).not.toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ); + + unmount(); + cleanup(); + }); +}); + +// ============================================================================= +// CMD+ENTER / CMD+SHIFT+ENTER – RETRY & NEW WORK ORDER (run loaded) +// ============================================================================= + +const FOLLOWED_RUN_ID = '11111111-1111-1111-1111-111111111111'; +const STEP_ID = '22222222-2222-2222-2222-222222222222'; + +const mockRetryableRun: RunDetail = { + id: FOLLOWED_RUN_ID, + work_order_id: '33333333-3333-3333-3333-333333333333', + work_order: { + id: '33333333-3333-3333-3333-333333333333', + workflow_id: 'workflow-1', + }, + state: 'success', + created_by: null, + starting_trigger: null, + started_at: '2024-01-01T00:00:00Z', + finished_at: '2024-01-01T00:01:00Z', + inserted_at: '2024-01-01T00:00:00Z', + steps: [ + { + id: STEP_ID, + job_id: '44444444-4444-4444-4444-444444444444', + job: { name: 'Test Job' }, + exit_reason: 'normal', + error_type: null, + started_at: '2024-01-01T00:00:00Z', + finished_at: '2024-01-01T00:01:00Z', + input_dataclip_id: null, + output_dataclip_id: null, + inserted_at: '2024-01-01T00:00:00Z', + }, + ], +}; + +describe('Header - Retry shortcuts (run loaded)', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + urlState.reset(); + vi.clearAllMocks(); + mockSubmitManualRun.mockResolvedValue({ data: { run_id: 'run-new-456' } }); + + mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { run_id: 'run-retried-789' } }), + }); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + async function createRetrySetup() { + urlState.setParam('run', FOLLOWED_RUN_ID); + + const setup = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + act(() => { + setup.stores.historyStore._setActiveRunForTesting(mockRetryableRun); + }); + + return setup; + } + + test('Cmd+Enter retries the loaded run instead of creating a new work order (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRetrySetup(); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/runs/${FOLLOWED_RUN_ID}/retry`), + expect.objectContaining({ method: 'POST' }) + ) + ); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Ctrl+Enter retries the loaded run instead of creating a new work order (Windows)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRetrySetup(); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Control>}{Enter}{/Control}'); + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/runs/${FOLLOWED_RUN_ID}/retry`), + expect.objectContaining({ method: 'POST' }) + ) + ); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+Shift+Enter navigates to run panel with custom input even when run is loaded (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRetrySetup(); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Shift>}{Enter}{/Shift}{/Meta}'); + + await waitFor(() => + expect(urlState.mockFns.updateSearchParams).toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ) + ); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining('/retry'), + expect.anything() + ); + + unmount(); + cleanup(); + }); +});