diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3014d74af4..d7cead72a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,10 @@ 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 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.
[#4735](https://github.com/OpenFn/lightning/pull/4735)
diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx
index 3924c8167a..07a3456ef1 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 {
@@ -236,6 +237,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 +303,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);
@@ -326,6 +384,46 @@ export function Header({
}
}, [provider, projectId, workflowId, isNewWorkflow]);
+ useKeyboardShortcut(
+ 'Control+Enter, Meta+Enter',
+ () => {
+ if (isRetryable) {
+ void handleRetryClick();
+ } else {
+ void handleRunClick();
+ }
+ },
+ 0,
+ {
+ enabled:
+ canRun &&
+ !isRunPanelOpen &&
+ !isIDEOpen &&
+ !isNewWorkflow &&
+ !!projectId &&
+ !!workflowId &&
+ (isRetryable || !!firstTriggerId),
+ }
+ );
+
+ useKeyboardShortcut(
+ 'Control+Shift+Enter, Meta+Shift+Enter',
+ () => {
+ handleRunWithCustomInputClick();
+ },
+ 0,
+ {
+ enabled:
+ canRun &&
+ !isRunPanelOpen &&
+ !isIDEOpen &&
+ !isNewWorkflow &&
+ !!projectId &&
+ !!workflowId &&
+ !!firstTriggerId,
+ }
+ );
+
useKeyboardShortcut(
'Control+s, Meta+s',
() => {
@@ -440,10 +538,13 @@ export function Header({
{projectId && workflowId && firstTriggerId && !isNewWorkflow && (
{
+ void (isRetryable ? handleRetryClick() : handleRunClick());
+ }}
onRunWithCustomInputClick={handleRunWithCustomInputClick}
disabled={!canRun || isRunPanelOpen || isIDEOpen}
isRunning={isSubmitting || runIsProcessing}
+ text={isRetryable ? 'Run (Retry)' : 'Run'}
/>
)}
) : (
-
+
);
const splitButtonClasses =
@@ -112,18 +112,33 @@ 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();
+ }
+ }}
>
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"
>
-
- !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
-
-
-
+
Open options
+
+
{/* Dropdown menu */}
{isDropdownOpen && (
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.
*
diff --git a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx
index 3093c5286a..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';
@@ -27,6 +29,7 @@ import {
createMockURLState,
getURLStateMockValue,
} from '../__helpers__/urlStateMocks';
+import { createWorkflowYDoc } from '../__helpers__/workflowFactory';
import { createMinimalWorkflowYDoc } from '../__helpers__/workflowStoreHelpers';
// =============================================================================
@@ -50,10 +53,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 +1011,416 @@ describe('Header - Guard Condition Interactions', () => {
cleanup();
});
});
+
+// =============================================================================
+// 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(
+ ,
+ { 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
+// =============================================================================
+
+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));
+ });
+ });
+
+ 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();
+ });
+});
+
+// =============================================================================
+// 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();
+ });
+});
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();
});
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',
- });
- });
- });
- });
-});