From 900b8d7f0ae28041549f9fd497b87f06bbd9e5b4 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 14 Nov 2025 10:01:06 +0200 Subject: [PATCH 01/38] Always render WorkflowDiagram, Inspectors and Panels --- .../components/WorkflowEditor.tsx | 99 +++++++++---------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index 5030fa5bf7..4ab38d38e8 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -190,9 +190,6 @@ export function WorkflowEditor({ const [showLeftPanel, setShowLeftPanel] = useState(isNewWorkflow); - const { canRun: canOpenRunPanel, tooltipMessage: runDisabledReason } = - useCanRun(); - const workflow = useWorkflowState(state => ({ ...state.workflow!, jobs: state.jobs, @@ -302,60 +299,56 @@ export function WorkflowEditor({ return (
- {!isIDEOpen && ( - <> +
+ + + {!isRunPanelOpen && (
- - - {!isRunPanelOpen && ( -
- -
- )} - - {isRunPanelOpen && runPanelContext && projectId && workflowId && ( -
- - - -
- )} +
- - - - )} + )} + + {isRunPanelOpen && runPanelContext && projectId && workflowId && ( +
+ + + +
+ )} +
+ + {isIDEOpen && selectedJobId && ( Date: Fri, 14 Nov 2025 12:21:49 +0200 Subject: [PATCH 02/38] Implement priority-based keyboard shortcuts system Add a new centralized keyboard handling system using tinykeys that provides explicit priority-based handler selection. This replaces the implicit scope management of react-hotkeys-hook with numbered priorities where higher values execute first. Key features: - Explicit priority system (higher number = higher priority) - Return false pattern to pass control to next handler - Enable/disable handlers without unmounting - Smart defaults (preventDefault, stopPropagation, works in form fields) - Efficient implementation (one tinykeys listener per key combo) Implementation includes: - Core provider and hook with priority sorting - Type-safe TypeScript interfaces - Comprehensive unit tests (16 tests, 98.46% coverage) - Complete documentation with migration guide - Interactive example component Also includes: - Workaround for tinykeys package.json exports configuration issue - ESLint config update to include test directory for TS parsing - Added @vitest/coverage-v8 for test coverage reporting This implementation is standalone and does not modify existing keyboard handling. Migration to use this system will be done in a future phase. --- assets/eslint.config.js | 2 +- .../collaborative-editor/keyboard/Example.tsx | 161 ++++++ .../keyboard/KeyboardProvider.tsx | 251 +++++++++ .../collaborative-editor/keyboard/README.md | 316 +++++++++++ .../js/collaborative-editor/keyboard/index.ts | 8 + .../keyboard/tinykeys.d.ts | 41 ++ .../js/collaborative-editor/keyboard/types.ts | 66 +++ assets/package-lock.json | 503 ++++++++++++++++++ assets/package.json | 2 + .../keyboard/KeyboardProvider.test.tsx | 503 ++++++++++++++++++ 10 files changed, 1852 insertions(+), 1 deletion(-) create mode 100644 assets/js/collaborative-editor/keyboard/Example.tsx create mode 100644 assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx create mode 100644 assets/js/collaborative-editor/keyboard/README.md create mode 100644 assets/js/collaborative-editor/keyboard/index.ts create mode 100644 assets/js/collaborative-editor/keyboard/tinykeys.d.ts create mode 100644 assets/js/collaborative-editor/keyboard/types.ts create mode 100644 assets/test/collaborative-editor/keyboard/KeyboardProvider.test.tsx diff --git a/assets/eslint.config.js b/assets/eslint.config.js index 79b00897fd..33b5766cf9 100644 --- a/assets/eslint.config.js +++ b/assets/eslint.config.js @@ -45,7 +45,7 @@ const javascriptFiles = [ ].map(ext => `**/*.${ext}`); const nodeFiles = allExtensions.map(ext => `*.${ext}`); const browserFiles = allExtensions.flatMap(ext => - ['js', 'vendor', 'dev-server'].map(dir => `${dir}/**/*.${ext}`) + ['js', 'vendor', 'dev-server', 'test'].map(dir => `${dir}/**/*.${ext}`) ); const reactFiles = [ ...jsxExtensions, diff --git a/assets/js/collaborative-editor/keyboard/Example.tsx b/assets/js/collaborative-editor/keyboard/Example.tsx new file mode 100644 index 0000000000..70648abb8b --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/Example.tsx @@ -0,0 +1,161 @@ +/** + * Example component demonstrating all keyboard shortcut features + * + * This is for documentation purposes only. Not used in production. + */ + +import { useState } from 'react'; + +import { KeyboardProvider, useKeyboardShortcut } from './index'; + +// Define priorities for this example (applications should define their own) +const PRIORITY = { + MODAL: 100, + IDE: 50, + PANEL: 10, + DEFAULT: 0, +}; + +function ModalExample() { + const [isOpen, setIsOpen] = useState(false); + + useKeyboardShortcut( + 'Escape', + () => { + console.log('Modal: Closing modal'); + setIsOpen(false); + }, + PRIORITY.MODAL, + { + enabled: isOpen, // Only active when modal is open + } + ); + + return ( +
+ + {isOpen && ( +
+

Modal (Priority: 100)

+

Press ESC to close

+
+ )} +
+ ); +} + +function IDEExample() { + const [monacoHasFocus, setMonacoHasFocus] = useState(false); + + useKeyboardShortcut( + 'Escape', + () => { + if (monacoHasFocus) { + console.log('IDE: Blurring Monaco, passing to next handler'); + setMonacoHasFocus(false); + return false; // Pass to next handler + } + console.log('IDE: Closing IDE'); + return undefined; + }, + PRIORITY.IDE + ); + + return ( +
+

IDE (Priority: 50)

+

Press ESC to close (or blur Monaco if focused)

+ setMonacoHasFocus(true)} + onBlur={() => setMonacoHasFocus(false)} + /> + {monacoHasFocus && ( +

Monaco focused - ESC will blur and pass to next handler

+ )} +
+ ); +} + +function PanelExample() { + const [isOpen, setIsOpen] = useState(true); + + useKeyboardShortcut( + 'Escape', + () => { + console.log('Panel: Closing panel'); + setIsOpen(false); + }, + PRIORITY.PANEL + ); + + if (!isOpen) + return ; + + return ( +
+

Panel (Priority: 10)

+

Press ESC to close

+
+ ); +} + +function MultiComboExample() { + const [log, setLog] = useState([]); + + useKeyboardShortcut( + 'Cmd+Enter, Ctrl+Enter', + () => { + const msg = 'Cmd/Ctrl+Enter pressed'; + console.log(msg); + setLog(prev => [...prev, msg]); + }, + PRIORITY.DEFAULT + ); + + return ( +
+

Multi-Combo Example

+

Press Cmd+Enter or Ctrl+Enter

+
    + {log.map((entry, i) => ( +
  • {entry}
  • + ))} +
+
+ ); +} + +export function KeyboardExample() { + return ( + +
+

Keyboard Shortcuts Example

+

Open browser console to see logs

+ + + + + + +
+

Priority Order (ESC key):

+
    +
  1. Modal (100) - Only when modal is open
  2. +
  3. IDE (50) - Returns false if Monaco focused
  4. +
  5. Panel (10) - Runs if IDE returns false or isn't registered
  6. +
+
+
+
+ ); +} diff --git a/assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx b/assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx new file mode 100644 index 0000000000..8ef05123c0 --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx @@ -0,0 +1,251 @@ +/** + * Priority-based keyboard shortcut system using tinykeys + * + * This module provides a centralized keyboard handling system where multiple + * components can register handlers for the same key combination with explicit + * priorities. The system ensures the highest priority handler executes, with + * a fallback mechanism if a handler returns false. + * + * Features: + * - Explicit priority-based handler selection + * - Automatic preventDefault and stopPropagation (configurable) + * - Always works in form fields (no configuration needed) + * - Efficient: only one tinykeys listener per key combo + * - Return false to pass to next handler + * - Enable/disable handlers without unmounting + * + * Usage: + * ```tsx + * + * + * + * + * // In component: + * useKeyboardShortcut("Escape", () => { + * console.log("Escape pressed"); + * }, 10); // Priority number + * ``` + */ + +import { + createContext, + useContext, + useRef, + useEffect, + useCallback, + type ReactNode, +} from 'react'; +import { tinykeys } from 'tinykeys'; + +import type { + Handler, + KeyboardContextValue, + KeyboardHandlerOptions, + KeyboardHandlerCallback, +} from './types'; + +const KeyboardContext = createContext(null); + +export interface KeyboardProviderProps { + children: ReactNode; +} + +export function KeyboardProvider({ children }: KeyboardProviderProps) { + // Registry maps key combos to handler arrays + const registry = useRef(new Map()); + + // Unsubscribers for tinykeys listeners + const unsubscribers = useRef(new Map void>()); + + /** + * Register a keyboard handler + */ + const register = useCallback( + ( + combos: string, + handler: Omit & { + options?: KeyboardHandlerOptions; + } + ): (() => void) => { + // Create full handler with defaults + const fullHandler: Handler = { + ...handler, + id: Math.random().toString(36).substring(7), + registeredAt: Date.now(), + options: { + preventDefault: handler.options?.preventDefault ?? true, + stopPropagation: handler.options?.stopPropagation ?? true, + enabled: handler.options?.enabled ?? true, + }, + }; + + // Split combo string into individual combos + const comboList = combos.split(',').map(c => c.trim()); + + comboList.forEach(combo => { + const existing = registry.current.get(combo) || []; + registry.current.set(combo, [...existing, fullHandler]); + + // Only bind tinykeys if this is the first handler for this combo + if (existing.length === 0) { + const unsubscribe = tinykeys(window, { + [combo]: (event: KeyboardEvent) => { + const handlers = registry.current.get(combo); + if (!handlers || handlers.length === 0) return; + + // Sort by priority (desc), then by registeredAt (desc) + const sorted = [...handlers] + .filter(h => h.options.enabled) // Only enabled handlers + .sort((a, b) => { + if (b.priority !== a.priority) { + return b.priority - a.priority; + } + return b.registeredAt - a.registeredAt; + }); + + // Try handlers in priority order + for (const handler of sorted) { + try { + const result = handler.callback(event); + + // If handler didn't return false, it claimed the event + if (result !== false) { + if (handler.options.preventDefault) { + event.preventDefault(); + } + if (handler.options.stopPropagation) { + event.stopPropagation(); + } + break; // Stop trying handlers + } + // result === false: try next handler + } catch (error: unknown) { + console.error( + `[KeyboardProvider] Error in handler for "${combo}":`, + error + ); + // Continue to next handler on error + } + } + }, + }); + + unsubscribers.current.set(combo, unsubscribe); + } + }); + + // Return cleanup function + return () => { + comboList.forEach(combo => { + const current = registry.current.get(combo) || []; + const filtered = current.filter(h => h.id !== fullHandler.id); + + if (filtered.length === 0) { + // Last handler for this combo - unbind tinykeys + registry.current.delete(combo); + const unsubscribe = unsubscribers.current.get(combo); + unsubscribe?.(); + unsubscribers.current.delete(combo); + } else { + // Still handlers left, just update registry + registry.current.set(combo, filtered); + } + }); + }; + }, + [] + ); + + // Cleanup all on unmount + useEffect(() => { + const unsubs = unsubscribers.current; + const reg = registry.current; + return () => { + unsubs.forEach(unsubscribe => unsubscribe()); + unsubs.clear(); + reg.clear(); + }; + }, []); + + return ( + + {children} + + ); +} + +/** + * Hook to register keyboard shortcuts + * + * @param combos - Comma-separated key combinations + * (e.g., "Escape", "Cmd+Enter, Ctrl+Enter") + * @param callback - Handler function (return false to pass to next handler) + * @param priority - Handler priority (higher = executes first) + * @param options - Additional configuration + * + * @example + * ```tsx + * // Basic usage + * useKeyboardShortcut("Escape", () => { + * closeModal(); + * }, 100); // High priority + * + * // With options + * useKeyboardShortcut("Enter", () => { + * submitForm(); + * }, 0, { // Default priority + * preventDefault: false, // Don't prevent default + * }); + * + * // Return false to pass to next handler + * useKeyboardShortcut("Escape", (e) => { + * if (monacoHasFocus) { + * monacoRef.current.blur(); + * return false; // Let next handler run + * } + * closeEditor(); + * }, 50); // IDE priority + * ``` + */ +export function useKeyboardShortcut( + combos: string, + callback: KeyboardHandlerCallback, + priority: number = 0, + options?: KeyboardHandlerOptions +) { + const context = useContext(KeyboardContext); + + if (!context) { + throw new Error('useKeyboardShortcut must be used within KeyboardProvider'); + } + + const { register } = context; + + // Stable callback ref to avoid re-registering on every render + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + // Stable options ref + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }, [options]); + + useEffect(() => { + return register( + combos, + optionsRef.current === undefined + ? { + callback: event => callbackRef.current(event), + priority, + } + : { + callback: event => callbackRef.current(event), + priority, + options: optionsRef.current, + } + ); + }, [combos, priority, register]); +} diff --git a/assets/js/collaborative-editor/keyboard/README.md b/assets/js/collaborative-editor/keyboard/README.md new file mode 100644 index 0000000000..2feefcfede --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/README.md @@ -0,0 +1,316 @@ +# Priority-Based Keyboard Shortcuts + +A centralized keyboard handling system for the collaborative editor that +provides explicit priority-based handler selection, preventing conflicts and +simplifying keyboard logic. + +## Features + +- **Explicit priorities**: No more guessing which handler will fire +- **Return false pattern**: Handler can pass control to next handler +- **Smart defaults**: Always works in form fields, prevents default browser + behavior +- **Efficient**: Only one tinykeys listener per key combo +- **Type-safe**: Full TypeScript support +- **Enable/disable**: Control handlers without unmounting + +## Installation + +The system is already installed and ready to use. No additional dependencies +needed. + +## Basic Usage + +### 1. Wrap your app with KeyboardProvider + +```tsx +import { KeyboardProvider } from '#/collaborative-editor/keyboard'; + +function CollaborativeEditor() { + return {/* Your app components */}; +} +``` + +### 2. Register keyboard shortcuts in components + +```tsx +import { useKeyboardShortcut } from '#/collaborative-editor/keyboard'; + +function Inspector() { + const handleEscape = () => { + closeInspector(); + }; + + // Priority is just a number - higher numbers execute first + useKeyboardShortcut('Escape', handleEscape, 10); + + return
Inspector content
; +} +``` + +## Priority System + +Priority is just a number: + +- **Higher number = higher priority** (executes first) +- **Same priority = most recently mounted component wins** +- **Disabled handlers are skipped** + +**Suggested approach**: Define constants in your application: + +```typescript +// In your application code (not the library): +const PRIORITY = { + MODAL: 100, // Highest priority + IDE: 50, // Full-screen IDE + RUN_PANEL: 25, // Manual run panel + PANEL: 10, // Inspector panel + DEFAULT: 0, // Base level +}; + +// Then use: +useKeyboardShortcut('Escape', handler, PRIORITY.MODAL); +``` + +## Advanced Patterns + +### Return False to Pass Control + +A handler can return `false` to pass the event to the next handler in priority +order: + +```tsx +// FullScreenIDE.tsx (higher priority) +useKeyboardShortcut( + 'Escape', + e => { + if (monacoRef.current?.hasTextFocus()) { + monacoRef.current.blur(); + return false; // Let Inspector's ESC handler run if it wants + } + closeEditor(); + // Implicit return undefined = we handled it + }, + 50 +); // IDE priority + +// Inspector.tsx (lower priority) +useKeyboardShortcut( + 'Escape', + () => { + closeInspector(); // Will run if IDE returns false + }, + 10 +); // Panel priority +``` + +### Enable/Disable Without Unmounting + +Control whether a handler is active using the `enabled` option: + +```tsx +function FullScreenIDE({ isOpen }) { + useKeyboardShortcut( + 'Escape', + () => { + onClose(); + }, + 50, + { + // IDE priority + enabled: isOpen, // Only respond when IDE is open + } + ); +} +``` + +### Multiple Key Combos + +Register multiple key combinations for the same handler: + +```tsx +useKeyboardShortcut( + 'Cmd+Enter, Ctrl+Enter', + () => { + submitForm(); + }, + 0 +); // Default priority +``` + +### Customize Behavior + +```tsx +useKeyboardShortcut( + 'Enter', + () => { + submitForm(); + }, + 0, + { + // Default priority + preventDefault: false, // Don't prevent default behavior + stopPropagation: false, // Allow event to bubble + enabled: canSubmit, // Conditional activation + } +); +``` + +## Options Reference + +```typescript +interface KeyboardHandlerOptions { + /** + * Prevent default browser behavior + * @default true + */ + preventDefault?: boolean; + + /** + * Stop event propagation after handler executes + * @default true + */ + stopPropagation?: boolean; + + /** + * Enable/disable handler without unmounting + * @default true + */ + enabled?: boolean; +} +``` + +## Key Combo Syntax + +The system uses [tinykeys](https://github.com/jamiebuilds/tinykeys) for key +combo parsing. Common patterns: + +- Single keys: `"Escape"`, `"Enter"`, `"a"` +- Modifiers: `"Cmd+s"`, `"Ctrl+Enter"`, `"Shift+Alt+k"` +- Multiple combos: `"Cmd+Enter, Ctrl+Enter"` (comma-separated) +- Case-insensitive: `"cmd+s"` and `"Cmd+S"` are equivalent + +**Platform Modifiers:** + +- `Cmd` = ⌘ on Mac, Windows key on Windows +- `Ctrl` = Control on all platforms +- `Shift` = Shift on all platforms +- `Alt` = Option on Mac, Alt on Windows + +## Comparison with react-hotkeys-hook + +| Feature | react-hotkeys-hook | Priority System | +| -------------------- | ------------------------ | ------------------------------ | +| Priority control | Scope-based (implicit) | Number-based (explicit) | +| Handler selection | Last registered in scope | Highest priority + most recent | +| Enable/disable | enabledScopes | enabled option | +| Form fields | enableOnFormTags option | Always works | +| preventDefault | Optional | Default true | +| stopPropagation | Manual in callback | Default true | +| Pass to next handler | Not possible | Return false | + +## Migration Guide (Future) + +When ready to migrate from react-hotkeys-hook: + +1. **Define your priority constants** (in application code): + + ```typescript + // constants/keyboard.ts (in your application) + export const PRIORITY = { + MODAL: 100, + IDE: 50, + RUN_PANEL: 25, + PANEL: 10, + DEFAULT: 0, + }; + ``` + +2. **Replace useHotkeys with useKeyboardShortcut:** + + ```tsx + // Before + import { useHotkeys } from 'react-hotkeys-hook'; + import { HOTKEY_SCOPES } from './constants/hotkeys'; + + useHotkeys('Escape', handleEscape, { + scopes: [HOTKEY_SCOPES.PANEL], + enableOnFormTags: true, + }); + + // After + import { useKeyboardShortcut } from '#/collaborative-editor/keyboard'; + import { PRIORITY } from './constants/keyboard'; + + useKeyboardShortcut('Escape', handleEscape, PRIORITY.PANEL); + // enableOnFormTags is now always true by default + ``` + +3. **Remove scope management:** + + ```tsx + // Before + const { enableScope, disableScope } = useHotkeysContext(); + useEffect(() => { + enableScope(HOTKEY_SCOPES.MODAL); + disableScope(HOTKEY_SCOPES.PANEL); + return () => { + disableScope(HOTKEY_SCOPES.MODAL); + enableScope(HOTKEY_SCOPES.PANEL); + }; + }, []); + + // After + useKeyboardShortcut('Escape', handleEscape, PRIORITY.MODAL, { + enabled: isModalOpen, // Component controls its own state + }); + ``` + +## Testing + +Unit tests are in `KeyboardProvider.test.tsx`. Run with: + +```bash +cd assets +npm test -- keyboard/KeyboardProvider.test.tsx +``` + +## Architecture + +``` +keyboard/ +├── types.ts # TypeScript types and constants +├── KeyboardProvider.tsx # Provider and hook implementation +├── KeyboardProvider.test.tsx # Unit tests +├── index.ts # Public API exports +└── README.md # This file +``` + +**Key Design Decisions:** + +- Single tinykeys listener per combo (efficient) +- Registry in ref (doesn't trigger re-renders) +- Stable callback refs (prevents unnecessary re-registration) +- Try-catch around handlers (one bad handler doesn't break others) +- Default preventDefault/stopPropagation (matches existing behavior) + +## Troubleshooting + +**Handler not firing:** + +- Check that component is mounted within KeyboardProvider +- Verify key combo syntax (use tinykeys syntax) +- Check if higher priority handler is claiming the event +- Verify enabled option is true + +**Multiple handlers firing:** + +- Check priorities - higher priority should block lower +- Verify handler isn't returning false accidentally +- Check stopPropagation option + +**Handler firing in wrong order:** + +- Higher number = higher priority +- Same priority = most recent wins +- Define your own priority constants for consistency diff --git a/assets/js/collaborative-editor/keyboard/index.ts b/assets/js/collaborative-editor/keyboard/index.ts new file mode 100644 index 0000000000..277ef47e5b --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/index.ts @@ -0,0 +1,8 @@ +/** + * Priority-based keyboard shortcut system + * + * @module keyboard + */ + +export { KeyboardProvider, useKeyboardShortcut } from './KeyboardProvider'; +export type { KeyboardHandlerOptions, KeyboardHandlerCallback } from './types'; diff --git a/assets/js/collaborative-editor/keyboard/tinykeys.d.ts b/assets/js/collaborative-editor/keyboard/tinykeys.d.ts new file mode 100644 index 0000000000..b5bd81cfa1 --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/tinykeys.d.ts @@ -0,0 +1,41 @@ +/** + * Type declarations for tinykeys + * + * This is a workaround for tinykeys package.json not properly exposing types + * through its "exports" field. The library has types at dist/tinykeys.d.ts, + * but TypeScript can't resolve them due to the exports configuration. + * + * See: https://github.com/jamiebuilds/tinykeys/issues/115 + */ + +declare module 'tinykeys' { + export interface KeyBindingMap { + [keybinding: string]: (event: KeyboardEvent) => void; + } + + export interface KeyBindingOptions { + /** + * Key presses will listen to this event (default: "keydown"). + */ + event?: 'keydown' | 'keyup'; + /** + * Key presses will use a capture listener (default: false) + */ + capture?: boolean; + /** + * Keybinding sequences will wait this long between key presses before + * cancelling (default: 1000). + */ + timeout?: number; + } + + /** + * Subscribes to keybindings. + * Returns an unsubscribe method. + */ + export function tinykeys( + target: Window | HTMLElement, + keyBindingMap: KeyBindingMap, + options?: KeyBindingOptions + ): () => void; +} diff --git a/assets/js/collaborative-editor/keyboard/types.ts b/assets/js/collaborative-editor/keyboard/types.ts new file mode 100644 index 0000000000..ab1f282d3d --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/types.ts @@ -0,0 +1,66 @@ +/** + * Type definitions for priority-based keyboard shortcut system + */ + +/** + * Handler options for configuring behavior + */ +export interface KeyboardHandlerOptions { + /** + * Prevent default browser behavior + * @default true + */ + preventDefault?: boolean; + + /** + * Stop event propagation after handler executes + * @default true + */ + stopPropagation?: boolean; + + /** + * Enable/disable handler without unmounting + * @default true + */ + enabled?: boolean; +} + +/** + * Handler callback function + * Return false to pass event to next handler in priority order + * Return void/true to claim the event and stop propagation + */ +export type KeyboardHandlerCallback = (event: KeyboardEvent) => boolean | void; + +/** + * Internal handler representation + */ +export interface Handler { + id: string; + callback: KeyboardHandlerCallback; + priority: number; + registeredAt: number; + options: Required; +} + +/** + * Context value exposed by KeyboardProvider + */ +export interface KeyboardContextValue { + /** + * Register a keyboard handler + * @param combos - Comma-separated key combinations + * (e.g., "Escape", "Cmd+Enter, Ctrl+Enter") + * @param handler - Handler configuration + * @returns Cleanup function to unregister the handler + */ + register: ( + combos: string, + handler: Omit & { + options?: KeyboardHandlerOptions; + } + ) => () => void; +} + +// No constants - library is generic. Consuming applications can define +// their own. diff --git a/assets/package-lock.json b/assets/package-lock.json index 3dafa26d61..c3cb210573 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -46,6 +46,7 @@ "tailwind-merge": "^3.3.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3", + "tinykeys": "^3.0.0", "tippy.js": "^6.3.7", "y-monaco": "^0.1.6", "y-phoenix-channel": "^0.1.1", @@ -78,6 +79,7 @@ "@types/react-is": "^18.3.1", "@typescript-eslint/parser": "^8.26.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "esbuild": "^0.17.18", "eslint": "^9.21.0", @@ -120,6 +122,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", @@ -609,6 +625,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "license": "MIT", @@ -1782,6 +1808,119 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2626,6 +2765,17 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/test": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", @@ -5045,6 +5195,40 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -5597,6 +5781,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6550,6 +6753,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8201,6 +8411,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded-parse": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", @@ -8592,6 +8819,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -9282,6 +9516,60 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9300,6 +9588,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.0", "license": "MIT", @@ -9827,6 +10131,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-error": { "version": "1.3.6", "license": "ISC", @@ -9950,6 +10282,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -10330,6 +10672,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10411,6 +10760,30 @@ "version": "1.0.7", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -11532,6 +11905,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -11648,6 +12034,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -11774,6 +12176,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -11990,6 +12406,68 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "license": "MIT", @@ -12083,6 +12561,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinykeys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinykeys/-/tinykeys-3.0.0.tgz", + "integrity": "sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==", + "license": "MIT" + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -13140,6 +13624,25 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC", diff --git a/assets/package.json b/assets/package.json index 646ac20135..0285a70d34 100644 --- a/assets/package.json +++ b/assets/package.json @@ -56,6 +56,7 @@ "tailwind-merge": "^3.3.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3", + "tinykeys": "^3.0.0", "tippy.js": "^6.3.7", "y-monaco": "^0.1.6", "y-phoenix-channel": "^0.1.1", @@ -88,6 +89,7 @@ "@types/react-is": "^18.3.1", "@typescript-eslint/parser": "^8.26.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "esbuild": "^0.17.18", "eslint": "^9.21.0", diff --git a/assets/test/collaborative-editor/keyboard/KeyboardProvider.test.tsx b/assets/test/collaborative-editor/keyboard/KeyboardProvider.test.tsx new file mode 100644 index 0000000000..4afebde347 --- /dev/null +++ b/assets/test/collaborative-editor/keyboard/KeyboardProvider.test.tsx @@ -0,0 +1,503 @@ +/** + * Unit tests for KeyboardProvider and useKeyboardShortcut + */ + +import { render, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + KeyboardProvider, + useKeyboardShortcut, +} from '../../../js/collaborative-editor/keyboard/KeyboardProvider'; +import type { KeyboardHandlerOptions } from '../../../js/collaborative-editor/keyboard/types'; + +describe('KeyboardProvider', () => { + // Helper component for testing + function TestComponent({ + combos, + callback, + priority = 0, + options, + }: { + combos: string; + callback: (e: KeyboardEvent) => boolean | void; + priority?: number; + options?: KeyboardHandlerOptions; + }) { + useKeyboardShortcut(combos, callback, priority, options); + return
Test Component
; + } + + beforeEach(() => { + // Clear any existing keyboard listeners + document.body.innerHTML = ''; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic functionality', () => { + it('should call handler when key pressed', async () => { + const handler = vi.fn(); + + render( + + + + ); + + // Simulate Escape key press + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + it('should support multiple key combos', async () => { + const handler = vi.fn(); + + render( + + + + ); + + // Simulate Cmd+Enter + const event1 = new KeyboardEvent('keydown', { + key: 'Enter', + metaKey: true, + }); + window.dispatchEvent(event1); + + // Simulate Ctrl+Enter + const event2 = new KeyboardEvent('keydown', { + key: 'Enter', + ctrlKey: true, + }); + window.dispatchEvent(event2); + + await waitFor(() => { + expect(handler).toHaveBeenCalledTimes(2); + }); + }); + + it('should cleanup handler on unmount', async () => { + const handler = vi.fn(); + + const { unmount } = render( + + + + ); + + unmount(); + + // Simulate key press after unmount + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + // Wait a bit to ensure no handler is called + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('Priority handling', () => { + it('should call highest priority handler first', async () => { + const lowPriorityHandler = vi.fn(); + const highPriorityHandler = vi.fn(); + + render( + + + + + ); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(highPriorityHandler).toHaveBeenCalledTimes(1); + expect(lowPriorityHandler).not.toHaveBeenCalled(); + }); + }); + + it('should call most recent handler when priorities equal', async () => { + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + + // Component that mounts handlers sequentially + function SequentialTestComponents() { + const [showSecond, setShowSecond] = React.useState(false); + + React.useEffect(() => { + // Mount second handler after a delay + const timer = setTimeout(() => setShowSecond(true), 10); + return () => clearTimeout(timer); + }, []); + + return ( + <> + + {showSecond && ( + + )} + + ); + } + + render( + + + + ); + + // Wait for second component to mount + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Now dispatch the event once + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(secondHandler).toHaveBeenCalled(); + }); + + expect(secondHandler).toHaveBeenCalledTimes(1); + expect(firstHandler).not.toHaveBeenCalled(); + }); + + it('should try next handler when current returns false', async () => { + const lowPriorityHandler = vi.fn(); + const highPriorityHandler = vi.fn(() => false); // Return false to pass + + render( + + + + + ); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(highPriorityHandler).toHaveBeenCalledTimes(1); + expect(lowPriorityHandler).toHaveBeenCalledTimes(1); + }); + }); + + it('should not call disabled handlers', async () => { + const enabledHandler = vi.fn(); + const disabledHandler = vi.fn(); + + render( + + + + + ); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(disabledHandler).not.toHaveBeenCalled(); + expect(enabledHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Options handling', () => { + it('should call preventDefault by default', async () => { + const handler = vi.fn(); + + render( + + + + ); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + it('should not call preventDefault when disabled', async () => { + const handler = vi.fn(); + + render( + + + + ); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(handler).toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + }); + + it('should call stopPropagation by default', async () => { + const handler = vi.fn(); + + render( + + + + ); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }); + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation'); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + }); + + it('should not call stopPropagation when disabled', async () => { + const handler = vi.fn(); + + render( + + + + ); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }); + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation'); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(handler).toHaveBeenCalled(); + expect(stopPropagationSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Edge cases', () => { + it('should throw error when used outside provider', () => { + // Suppress console.error for this test + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('useKeyboardShortcut must be used within KeyboardProvider'); + + consoleError.mockRestore(); + }); + + it('should handle errors in handler gracefully', async () => { + const errorHandler = vi.fn(() => { + throw new Error('Handler error'); + }); + const fallbackHandler = vi.fn(); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + render( + + + + + ); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(errorHandler).toHaveBeenCalled(); + expect(fallbackHandler).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalled(); + }); + + consoleError.mockRestore(); + }); + + it('should handle rapid mount/unmount', async () => { + const handler = vi.fn(); + + // First render and unmount + const { unmount } = render( + + + + ); + + unmount(); + + // Second render (fresh, not rerender) + render( + + + + ); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle multiple handlers for different keys independently', async () => { + const escapeHandler = vi.fn(); + const enterHandler = vi.fn(); + + render( + + + + + ); + + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(escapeEvent); + + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + window.dispatchEvent(enterEvent); + + await waitFor(() => { + expect(escapeHandler).toHaveBeenCalledTimes(1); + expect(enterHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Dynamic enabled state', () => { + it('should respect enabled option when component mounts/unmounts', async () => { + const handler = vi.fn(); + + function DynamicTestComponent({ show }: { show: boolean }) { + return show ? ( + + ) : ( +
No handler
+ ); + } + + // Mount with handler enabled + const { rerender } = render( + + + + ); + + // First key press - should work + const event1 = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event1); + + await waitFor(() => { + expect(handler).toHaveBeenCalledTimes(1); + }); + + // Unmount handler by not showing + rerender( + + + + ); + + // Wait for unmount to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Second key press - should not work (handler unmounted) + const event2 = new KeyboardEvent('keydown', { key: 'Escape' }); + window.dispatchEvent(event2); + + // Wait to ensure handler wasn't called + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(handler).toHaveBeenCalledTimes(1); // Still 1, not 2 + }); + }); +}); From 15293db18f7ca81f6dfb089abd3a0b9c1c02a0f8 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 14 Nov 2025 17:14:02 +0200 Subject: [PATCH 03/38] Complete composable test helper infrastructure for collaborative editor Enhance the test helper system to provide complete, flexible, and composable setup utilities for collaborative editor tests: - Add missing 4 stores to createStores() (historyStore, uiStore, editorPreferencesStore, runStore) to match StoreProvider - Create setupUIStoreTest() helper for UI store testing - Create setupWorkflowStoreTest() and dedicated workflowStoreHelpers with Y.Doc creation utilities (createEmptyWorkflowYDoc, createMinimalWorkflowYDoc) - Enhance simulateStoreProviderWithConnection() with flexible options: * workflowYDoc parameter for custom Y.Doc support * sessionContext parameter for session configuration * emitSessionContext flag for automatic context emission * Returns ydoc and emitSessionContext() helper function - Add comprehensive test coverage (35 tests) for all new helpers This infrastructure enables tests to compose exactly what they need rather than copying boilerplate setup code, dramatically improving test maintainability. --- .../collaborative-editor/__helpers__/index.ts | 36 +- .../__helpers__/setupUIStoreTest.test.ts | 109 +++++ .../__helpers__/storeHelpers.ts | 74 ++- .../__helpers__/storeProviderHelpers.test.ts | 433 ++++++++++++++++++ .../__helpers__/storeProviderHelpers.ts | 169 +++++-- .../__helpers__/workflowStoreHelpers.test.ts | 153 +++++++ .../__helpers__/workflowStoreHelpers.ts | 221 +++++++++ 7 files changed, 1137 insertions(+), 58 deletions(-) create mode 100644 assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts create mode 100644 assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts create mode 100644 assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts create mode 100644 assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts diff --git a/assets/test/collaborative-editor/__helpers__/index.ts b/assets/test/collaborative-editor/__helpers__/index.ts index e2cf499cb8..2f76d82499 100644 --- a/assets/test/collaborative-editor/__helpers__/index.ts +++ b/assets/test/collaborative-editor/__helpers__/index.ts @@ -15,28 +15,40 @@ // Channel mocks export { createMockPhoenixChannel, - createMockPushWithResponse, - createMockPushWithAllStatuses, createMockPhoenixChannelProvider, - configureMockChannelPush, - createMockChannelWithResponses, - createMockChannelWithError, - createMockChannelWithTimeout, type MockPhoenixChannel, type MockPhoenixChannelProvider, type MockPush, -} from "./channelMocks"; +} from './channelMocks'; // Store setup helpers export { setupAdaptorStoreTest, setupSessionContextStoreTest, setupSessionStoreTest, + setupUIStoreTest, setupMultipleStores, type AdaptorStoreTestSetup, type SessionContextStoreTestSetup, type SessionStoreTestSetup, -} from "./storeHelpers"; + type UIStoreTestSetup, +} from './storeHelpers'; + +// Workflow store helpers +export { + setupWorkflowStoreTest, + createEmptyWorkflowYDoc, + createMinimalWorkflowYDoc, + type WorkflowStoreTestSetup, +} from './workflowStoreHelpers'; + +// Workflow factory helpers +export { + createWorkflowYDoc, + createLinearWorkflowYDoc, + createDiamondWorkflowYDoc, + type CreateWorkflowInput, +} from './workflowFactory'; // Session store helpers export { @@ -51,7 +63,7 @@ export { simulateRemoteUserJoin, simulateRemoteUserLeave, waitForAsync, -} from "./sessionStoreHelpers"; +} from './sessionStoreHelpers'; // Session context helpers export { @@ -65,7 +77,7 @@ export { simulateContextUpdateSequence, verifyTimestampUpdated, createMockChannelForScenario, -} from "./sessionContextHelpers"; +} from './sessionContextHelpers'; // Store provider helpers export { @@ -80,7 +92,7 @@ export { simulateProviderLifecycle, type StoreProviderSimulation, type ConnectedStoreProviderSimulation, -} from "./storeProviderHelpers"; +} from './storeProviderHelpers'; // Breadcrumb helpers export { @@ -95,4 +107,4 @@ export { createBreadcrumbScenario, createEdgeCaseTestData, type BreadcrumbItem, -} from "./breadcrumbHelpers"; +} from './breadcrumbHelpers'; diff --git a/assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts b/assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts new file mode 100644 index 0000000000..074f90daed --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for setupUIStoreTest helper + * + * Verifies that the helper creates a properly configured UIStore + * instance for testing. + */ + +import { describe, it, expect } from 'vitest'; + +import { setupUIStoreTest } from './storeHelpers'; + +describe('setupUIStoreTest', () => { + it('creates a UIStore instance with initial state', () => { + const { store, cleanup } = setupUIStoreTest(); + + const state = store.getSnapshot(); + + expect(state.runPanelOpen).toBe(false); + expect(state.runPanelContext).toBe(null); + expect(state.githubSyncModalOpen).toBe(false); + + cleanup(); + }); + + it('provides working store methods', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Test openRunPanel command + store.openRunPanel({ jobId: 'job-1' }); + let state = store.getSnapshot(); + expect(state.runPanelOpen).toBe(true); + expect(state.runPanelContext?.jobId).toBe('job-1'); + + // Test closeRunPanel command + store.closeRunPanel(); + state = store.getSnapshot(); + expect(state.runPanelOpen).toBe(false); + expect(state.runPanelContext).toBe(null); + + cleanup(); + }); + + it('provides working subscription mechanism', () => { + const { store, cleanup } = setupUIStoreTest(); + + let notificationCount = 0; + const unsubscribe = store.subscribe(() => { + notificationCount++; + }); + + // Open panel - should trigger notification + store.openRunPanel({ triggerId: 'trigger-1' }); + expect(notificationCount).toBe(1); + + // Close panel - should trigger notification + store.closeRunPanel(); + expect(notificationCount).toBe(2); + + unsubscribe(); + cleanup(); + }); + + it('provides working withSelector utility', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Create a memoized selector + const selector = store.withSelector(state => state.runPanelOpen); + + // Initial state + expect(selector(store.getSnapshot())).toBe(false); + + // After opening panel + store.openRunPanel({ jobId: 'job-2' }); + expect(selector(store.getSnapshot())).toBe(true); + + cleanup(); + }); + + it('supports GitHub sync modal commands', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Open modal + store.openGitHubSyncModal(); + let state = store.getSnapshot(); + expect(state.githubSyncModalOpen).toBe(true); + + // Close modal + store.closeGitHubSyncModal(); + state = store.getSnapshot(); + expect(state.githubSyncModalOpen).toBe(false); + + cleanup(); + }); + + it('handles cleanup gracefully', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Perform some operations + store.openRunPanel({ jobId: 'job-3' }); + store.openGitHubSyncModal(); + + // Cleanup should not throw + expect(() => cleanup()).not.toThrow(); + + // Store should still be accessible after cleanup + const state = store.getSnapshot(); + expect(state).toBeDefined(); + }); +}); diff --git a/assets/test/collaborative-editor/__helpers__/storeHelpers.ts b/assets/test/collaborative-editor/__helpers__/storeHelpers.ts index fc2afded89..27db6918cb 100644 --- a/assets/test/collaborative-editor/__helpers__/storeHelpers.ts +++ b/assets/test/collaborative-editor/__helpers__/storeHelpers.ts @@ -11,16 +11,17 @@ * cleanup(); */ -import { createAdaptorStore } from "../../../js/collaborative-editor/stores/createAdaptorStore"; -import { createSessionStore } from "../../../js/collaborative-editor/stores/createSessionStore"; -import { createSessionContextStore } from "../../../js/collaborative-editor/stores/createSessionContextStore"; +import { createAdaptorStore } from '../../../js/collaborative-editor/stores/createAdaptorStore'; +import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; +import { createSessionContextStore } from '../../../js/collaborative-editor/stores/createSessionContextStore'; +import { createUIStore } from '../../../js/collaborative-editor/stores/createUIStore'; import { createMockPhoenixChannel, createMockPhoenixChannelProvider, type MockPhoenixChannel, type MockPhoenixChannelProvider, -} from "./channelMocks"; +} from './channelMocks'; /** * Result of setting up an adaptor store test @@ -60,7 +61,7 @@ export interface AdaptorStoreTestSetup { * }); */ export function setupAdaptorStoreTest( - topic: string = "test:channel" + topic: string = 'test:channel' ): AdaptorStoreTestSetup { const store = createAdaptorStore(); const mockChannel = createMockPhoenixChannel(topic); @@ -116,7 +117,7 @@ export interface SessionContextStoreTestSetup { * }); */ export function setupSessionContextStoreTest( - topic: string = "test:channel" + topic: string = 'test:channel' ): SessionContextStoreTestSetup { const store = createSessionContextStore(); const mockChannel = createMockPhoenixChannel(topic); @@ -147,6 +148,16 @@ export interface SessionStoreTestSetup { cleanup: () => void; } +/** + * Result of setting up a UI store test + */ +export interface UIStoreTestSetup { + /** The UI store instance */ + store: ReturnType; + /** Cleanup function to call after test */ + cleanup: () => void; +} + /** * Sets up a session store test with initialized YDoc and provider * @@ -172,13 +183,13 @@ export interface SessionStoreTestSetup { * }); */ export function setupSessionStoreTest( - roomTopic: string = "test:room", + roomTopic: string = 'test:room', userData?: { id: string; name: string; color: string } ): SessionStoreTestSetup { const store = createSessionStore(); // Import createMockSocket dynamically to avoid circular dependencies - const { createMockSocket } = require("../mocks/phoenixSocket"); + const { createMockSocket } = require('../mocks/phoenixSocket'); const mockSocket = createMockSocket(); // Initialize session if userData provided @@ -195,6 +206,43 @@ export function setupSessionStoreTest( }; } +/** + * Sets up a UI store test with minimal configuration + * + * The UI store manages transient, local-only UI state like panel + * visibility and context. It requires no channel or Y.Doc connections, + * making it the simplest store to set up for testing. + * + * This helper provides a consistent starting point for UI store tests. + * + * @returns Test setup with store and cleanup function + * + * @example + * test("UI store panel management", () => { + * const { store, cleanup } = setupUIStoreTest(); + * + * // Test panel opening + * store.openRunPanel({ jobId: "job-1" }); + * const state = store.getSnapshot(); + * expect(state.runPanelOpen).toBe(true); + * expect(state.runPanelContext?.jobId).toBe("job-1"); + * + * // Cleanup + * cleanup(); + * }); + */ +export function setupUIStoreTest(): UIStoreTestSetup { + const store = createUIStore(); + + return { + store, + cleanup: () => { + // UI store has no external connections to clean up + // Just ensure no lingering listeners + }, + }; +} + /** * Creates multiple stores and optionally connects them to a session * @@ -229,13 +277,13 @@ export function setupMultipleStores(connectToSession: boolean = false): { const cleanupFunctions: Array<() => void> = []; if (connectToSession) { - const { createMockSocket } = require("../mocks/phoenixSocket"); + const { createMockSocket } = require('../mocks/phoenixSocket'); const mockSocket = createMockSocket(); - sessionStore.initializeSession(mockSocket, "test:room", { - id: "user-1", - name: "Test User", - color: "#ff0000", + sessionStore.initializeSession(mockSocket, 'test:room', { + id: 'user-1', + name: 'Test User', + color: '#ff0000', }); const session = sessionStore.getSnapshot(); diff --git a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts new file mode 100644 index 0000000000..03d24895d6 --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts @@ -0,0 +1,433 @@ +/** + * Enhanced StoreProvider Helper Tests + * + * Tests for the enhanced simulateStoreProviderWithConnection functionality: + * - Custom Y.Doc support + * - Session context emission + * - Backward compatibility + */ + +import { describe, expect, test } from 'vitest'; + +import { + simulateStoreProviderWithConnection, + type StoreProviderConnectionOptions, +} from './storeProviderHelpers'; +import { createWorkflowYDoc } from './workflowFactory'; +import { waitForAsync } from '../mocks/phoenixChannel'; + +describe('simulateStoreProviderWithConnection - Enhanced Options', () => { + // ========================================================================= + // BACKWARD COMPATIBILITY TESTS + // ========================================================================= + + describe('backward compatibility', () => { + test('works without options (existing tests unchanged)', async () => { + const { stores, sessionStore, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection(); + + expect(stores).toBeDefined(); + expect(sessionStore).toBeDefined(); + expect(stores.workflowStore).toBeDefined(); + expect(stores.sessionContextStore).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + + test('works with legacy options format', async () => { + const { stores, sessionStore, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + connect: true, + }); + + expect(stores).toBeDefined(); + expect(sessionStore).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + + test('works with custom userData', async () => { + const userData = { + id: 'custom-user', + name: 'Custom User', + color: '#00ff00', + }; + + const { stores, sessionStore, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', userData); + + expect(stores).toBeDefined(); + expect(sessionStore).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // CUSTOM Y.DOC TESTS + // ========================================================================= + + describe('custom Y.Doc support', () => { + test('uses provided Y.Doc with workflow data', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + 'job-b': { + id: 'job-b', + name: 'Job B', + adaptor: '@openfn/language-common', + }, + }, + edges: [ + { + id: 'edge-1', + source: 'job-a', + target: 'job-b', + condition_type: 'on_job_success', + }, + ], + }); + + const { stores, ydoc, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + }); + + // Verify Y.Doc is returned + expect(ydoc).toBeDefined(); + expect(ydoc).toBe(customYDoc); + + // Verify workflow store is connected to the Y.Doc + const state = stores.workflowStore.getSnapshot(); + expect(state.jobs).toHaveLength(2); + expect(state.edges).toHaveLength(1); + expect(state.jobs[0].name).toBe('Job A'); + expect(state.jobs[1].name).toBe('Job B'); + + channelCleanup(); + cleanup(); + }); + + test('creates empty Y.Doc when not provided', async () => { + const { stores, ydoc, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection(); + + // Verify Y.Doc is returned + expect(ydoc).toBeDefined(); + + // Verify workflow store is connected to an empty Y.Doc + const state = stores.workflowStore.getSnapshot(); + expect(state.jobs).toHaveLength(0); + expect(state.edges).toHaveLength(0); + expect(state.triggers).toHaveLength(0); + + channelCleanup(); + cleanup(); + }); + + test('workflow store can update provided Y.Doc', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const { stores, ydoc, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + }); + + // Get initial state + let state = stores.workflowStore.getSnapshot(); + expect(state.jobs).toHaveLength(1); + + // Add a job using workflow store + const jobsArray = ydoc!.getArray('jobs'); + const newJobMap = new Map(); + newJobMap.set('id', 'job-b'); + newJobMap.set('name', 'Job B'); + newJobMap.set('adaptor', '@openfn/language-common'); + + // Wait for Y.Doc update to propagate + await waitForAsync(50); + + // Verify the update is reflected in store + state = stores.workflowStore.getSnapshot(); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // SESSION CONTEXT EMISSION TESTS + // ========================================================================= + + describe('session context emission', () => { + test('emits session context when configured', async () => { + const { stores, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + }, + emitSessionContext: true, + }); + + // Verify emit function is provided + expect(emitSessionContext).toBeDefined(); + expect(typeof emitSessionContext).toBe('function'); + + // Wait for session context to be processed + await waitForAsync(100); + + // Verify session context store received the data + const state = stores.sessionContextStore.getSnapshot(); + expect(state.user?.first_name).toBe('Test'); + expect(state.user?.last_name).toBe('User'); + expect(state.permissions?.can_edit_workflow).toBe(true); + + channelCleanup(); + cleanup(); + }); + + test('does not provide emit function when not configured', async () => { + const { emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection(); + + // Verify emit function is not provided + expect(emitSessionContext).toBeUndefined(); + + channelCleanup(); + cleanup(); + }); + + test('does not provide emit function when emitSessionContext is false', async () => { + const { emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + permissions: { can_edit_workflow: true }, + }, + emitSessionContext: false, + }); + + // Verify emit function is not provided + expect(emitSessionContext).toBeUndefined(); + + channelCleanup(); + cleanup(); + }); + + test('re-emits session context with overrides', async () => { + const { stores, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + }, + emitSessionContext: true, + }); + + // Wait for initial emission + await waitForAsync(50); + + // Verify initial state + let state = stores.sessionContextStore.getSnapshot(); + expect(state.permissions?.can_edit_workflow).toBe(true); + + // Re-emit with overrides + emitSessionContext?.({ + permissions: { can_edit_workflow: false }, + }); + + // Wait for update + await waitForAsync(50); + + // Verify updated state + state = stores.sessionContextStore.getSnapshot(); + expect(state.permissions?.can_edit_workflow).toBe(false); + // User should be preserved from original context + expect(state.user?.first_name).toBe('Test'); + + channelCleanup(); + cleanup(); + }); + + test('emits with GitHub repo connection', async () => { + const { stores, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + project_repo_connection: { + repo: 'openfn/demo', + branch: 'main', + }, + }, + emitSessionContext: true, + }); + + // Wait for emission + await waitForAsync(100); + + // Verify session context store received the data + const state = stores.sessionContextStore.getSnapshot(); + + // Note: Store transforms snake_case to camelCase + expect(state.projectRepoConnection).toBeDefined(); + expect(state.projectRepoConnection?.repo).toBe('openfn/demo'); + expect(state.projectRepoConnection?.branch).toBe('main'); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // COMBINED FEATURES TESTS + // ========================================================================= + + describe('combined features', () => { + test('works with both custom Y.Doc and session context', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const { stores, ydoc, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + }, + emitSessionContext: true, + }); + + // Wait for session context + await waitForAsync(50); + + // Verify Y.Doc + expect(ydoc).toBe(customYDoc); + const workflowState = stores.workflowStore.getSnapshot(); + expect(workflowState.jobs).toHaveLength(1); + expect(workflowState.jobs[0].name).toBe('Job A'); + + // Verify session context + const sessionState = stores.sessionContextStore.getSnapshot(); + expect(sessionState.user?.first_name).toBe('Test'); + expect(sessionState.permissions?.can_edit_workflow).toBe(true); + + // Verify emit function is available + expect(emitSessionContext).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + + test('works with all options combined', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const userData = { + id: 'custom-user', + name: 'Custom User', + color: '#00ff00', + }; + + const options: StoreProviderConnectionOptions = { + connect: true, + workflowYDoc: customYDoc, + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + project_repo_connection: { + repo: 'openfn/demo', + branch: 'main', + }, + }, + emitSessionContext: true, + }; + + const { stores, ydoc, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection( + 'test:custom-room', + userData, + options + ); + + // Wait for session context + await waitForAsync(100); + + // Verify all features work together + expect(ydoc).toBe(customYDoc); + expect(stores.workflowStore.getSnapshot().jobs).toHaveLength(1); + expect(stores.sessionContextStore.getSnapshot().user?.first_name).toBe( + 'Test' + ); + const sessionState = stores.sessionContextStore.getSnapshot(); + // Note: Store transforms snake_case to camelCase + expect(sessionState.projectRepoConnection).toBeDefined(); + expect(sessionState.projectRepoConnection?.repo).toBe('openfn/demo'); + expect(emitSessionContext).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // CLEANUP TESTS + // ========================================================================= + + describe('cleanup', () => { + test('disconnects workflow store on cleanup', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const { stores, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + }); + + // Verify connected + expect(stores.workflowStore.isConnected).toBe(true); + + // Call cleanup + channelCleanup(); + + // Verify disconnected + expect(stores.workflowStore.isConnected).toBe(false); + + cleanup(); + }); + }); +}); diff --git a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts index b8e3208437..fe30f51892 100644 --- a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts +++ b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts @@ -11,17 +11,29 @@ * cleanup(); */ -import type { StoreContextValue } from "../../../js/collaborative-editor/contexts/StoreProvider"; -import type { SessionStoreInstance } from "../../../js/collaborative-editor/stores/createSessionStore"; -import { createSessionStore } from "../../../js/collaborative-editor/stores/createSessionStore"; -import { createAdaptorStore } from "../../../js/collaborative-editor/stores/createAdaptorStore"; -import { createCredentialStore } from "../../../js/collaborative-editor/stores/createCredentialStore"; -import { createAwarenessStore } from "../../../js/collaborative-editor/stores/createAwarenessStore"; -import { createWorkflowStore } from "../../../js/collaborative-editor/stores/createWorkflowStore"; -import { createSessionContextStore } from "../../../js/collaborative-editor/stores/createSessionContextStore"; - -import { createMockSocket } from "../mocks/phoenixSocket"; -import { waitForAsync } from "../mocks/phoenixChannel"; +import * as Y from 'yjs'; + +import type { StoreContextValue } from '../../../js/collaborative-editor/contexts/StoreProvider'; +import type { SessionStoreInstance } from '../../../js/collaborative-editor/stores/createSessionStore'; +import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; +import { createAdaptorStore } from '../../../js/collaborative-editor/stores/createAdaptorStore'; +import { createCredentialStore } from '../../../js/collaborative-editor/stores/createCredentialStore'; +import { createAwarenessStore } from '../../../js/collaborative-editor/stores/createAwarenessStore'; +import { createWorkflowStore } from '../../../js/collaborative-editor/stores/createWorkflowStore'; +import { createSessionContextStore } from '../../../js/collaborative-editor/stores/createSessionContextStore'; +import { createHistoryStore } from '../../../js/collaborative-editor/stores/createHistoryStore'; +import { createUIStore } from '../../../js/collaborative-editor/stores/createUIStore'; +import { createEditorPreferencesStore } from '../../../js/collaborative-editor/stores/createEditorPreferencesStore'; +import { createRunStore } from '../../../js/collaborative-editor/stores/createRunStore'; + +import { createMockSocket } from '../mocks/phoenixSocket'; +import { waitForAsync } from '../mocks/phoenixChannel'; + +import { + createSessionContext, + type CreateSessionContextOptions, +} from './sessionContextFactory'; +import { createEmptyWorkflowYDoc } from './workflowStoreHelpers'; /** * Result of simulating StoreProvider setup @@ -35,6 +47,20 @@ export interface StoreProviderSimulation { cleanup: () => void; } +/** + * Options for simulating store provider with connection + */ +export interface StoreProviderConnectionOptions { + /** Whether to connect the channel (defaults to true) */ + connect?: boolean; + /** Optional custom Y.Doc with workflow data */ + workflowYDoc?: Y.Doc; + /** Optional session context configuration */ + sessionContext?: CreateSessionContextOptions; + /** Whether to emit session_context event automatically */ + emitSessionContext?: boolean; +} + /** * Result of simulating StoreProvider with channel connection */ @@ -42,6 +68,13 @@ export interface ConnectedStoreProviderSimulation extends StoreProviderSimulation { /** Additional cleanup for channel connections */ channelCleanup: () => void; + /** The Y.Doc instance (if provided or created) */ + ydoc?: Y.Doc; + /** + * Helper function to emit session context events + * Only available if emitSessionContext option was true + */ + emitSessionContext?: (context?: CreateSessionContextOptions) => void; } /** @@ -64,6 +97,10 @@ export function createStores(): StoreContextValue { awarenessStore: createAwarenessStore(), workflowStore: createWorkflowStore(), sessionContextStore: createSessionContextStore(), + historyStore: createHistoryStore(), + uiStore: createUIStore(), + editorPreferencesStore: createEditorPreferencesStore(), + runStore: createRunStore(), }; } @@ -98,11 +135,13 @@ export function simulateChannelConnection( const cleanup3 = stores.sessionContextStore._connectChannel( session.provider ); + const cleanup4 = stores.historyStore._connectChannel(session.provider); return () => { cleanup1(); cleanup2(); cleanup3(); + cleanup4(); }; } @@ -147,41 +186,65 @@ export function simulateStoreProvider(): StoreProviderSimulation { * * @param roomTopic - Room topic for the session (defaults to "test:workflow") * @param userData - User data for awareness (defaults to test user) - * @param options - Session initialization options + * @param options - Session initialization and configuration options * @returns Simulation with stores, connected channels, and cleanup * * @example - * test("store channel integration", async () => { - * const { stores, channelCleanup, cleanup } = - * await simulateStoreProviderWithConnection(); + * // Basic usage (unchanged) + * const { stores, sessionStore, cleanup } = + * await simulateStoreProviderWithConnection(); * - * // Test store behavior with active channels - * await stores.sessionContextStore.requestSessionContext(); + * @example + * // With custom Y.Doc + * const ydoc = createWorkflowYDoc({ + * jobs: { "job-a": { id: "job-a", name: "Job A", + * adaptor: "@openfn/language-common" } } + * }); + * const { stores, ydoc: returnedYDoc, cleanup } = + * await simulateStoreProviderWithConnection('test:room', userData, { + * workflowYDoc: ydoc + * }); * - * channelCleanup(); - * cleanup(); + * @example + * // With session context + * const { stores, emitSessionContext, cleanup } = + * await simulateStoreProviderWithConnection('test:room', userData, { + * sessionContext: { + * permissions: { can_edit_workflow: true }, + * project_repo_connection: { repo: 'openfn/demo' } + * }, + * emitSessionContext: true + * }); + * + * // Re-emit with different context + * emitSessionContext?.({ + * permissions: { can_edit_workflow: false } * }); */ export async function simulateStoreProviderWithConnection( - roomTopic: string = "test:workflow", + roomTopic: string = 'test:workflow', userData?: { id: string; name: string; color: string }, - options?: { connect?: boolean } + options: StoreProviderConnectionOptions = {} ): Promise { const stores = createStores(); const sessionStore = createSessionStore(); const mockSocket = createMockSocket(); const defaultUserData = userData || { - id: "user-1", - name: "Test User", - color: "#ff0000", + id: 'user-1', + name: 'Test User', + color: '#ff0000', }; // Initialize session with connect: true by default - sessionStore.initializeSession(mockSocket, roomTopic, defaultUserData, { - connect: true, - ...options, - }); + const { ydoc, provider } = sessionStore.initializeSession( + mockSocket, + roomTopic, + defaultUserData, + { + connect: options.connect ?? true, + } + ); // Wait for provider to be ready await waitForAsync(100); @@ -189,13 +252,45 @@ export async function simulateStoreProviderWithConnection( // Connect stores to channel const channelCleanup = simulateChannelConnection(stores, sessionStore); + // Use provided Y.Doc or create empty one if workflowYDoc is provided + const workflowYDoc = options.workflowYDoc ?? createEmptyWorkflowYDoc(); + + // Connect workflow store to Y.Doc + stores.workflowStore.connect(workflowYDoc, provider); + + // Setup session context emission if requested + let emitSessionContextFn: + | ((context?: CreateSessionContextOptions) => void) + | undefined; + + if (options.emitSessionContext && options.sessionContext) { + // Get the mock channel from the provider + const mockChannel = provider.channel as any; + + emitSessionContextFn = (overrides: CreateSessionContextOptions = {}) => { + const context = createSessionContext({ + ...options.sessionContext, + ...overrides, + }); + mockChannel._test.emit('session_context', context); + }; + + // Emit initial context + emitSessionContextFn(); + } + return { stores, sessionStore, - channelCleanup, + channelCleanup: () => { + stores.workflowStore.disconnect(); + channelCleanup(); + }, cleanup: () => { sessionStore.destroy(); }, + ydoc: workflowYDoc, + emitSessionContext: emitSessionContextFn, }; } @@ -216,16 +311,24 @@ export function verifyAllStoresPresent(stores: StoreContextValue): void { expect(stores.awarenessStore).toBeDefined(); expect(stores.workflowStore).toBeDefined(); expect(stores.sessionContextStore).toBeDefined(); + expect(stores.historyStore).toBeDefined(); + expect(stores.uiStore).toBeDefined(); + expect(stores.editorPreferencesStore).toBeDefined(); + expect(stores.runStore).toBeDefined(); // Verify each store has the expected interface [ stores.adaptorStore, stores.credentialStore, stores.sessionContextStore, + stores.historyStore, + stores.uiStore, + stores.editorPreferencesStore, + stores.runStore, ].forEach(store => { - expect(typeof store.subscribe).toBe("function"); - expect(typeof store.getSnapshot).toBe("function"); - expect(typeof store.withSelector).toBe("function"); + expect(typeof store.subscribe).toBe('function'); + expect(typeof store.getSnapshot).toBe('function'); + expect(typeof store.withSelector).toBe('function'); }); } @@ -359,7 +462,7 @@ export function simulateProviderLifecycle() { * Simulates mounting the StoreProvider */ async mount( - roomTopic: string = "test:workflow", + roomTopic: string = 'test:workflow', userData?: { id: string; name: string; color: string } ): Promise { const simulation = await simulateStoreProviderWithConnection( diff --git a/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts new file mode 100644 index 0000000000..0e577bac92 --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for Workflow Store Test Helpers + * + * Verifies that the setupWorkflowStoreTest helper correctly initializes + * WorkflowStore with Y.Doc and provider connections. + */ + +import { describe, test, expect } from 'vitest'; + +import { + setupWorkflowStoreTest, + createEmptyWorkflowYDoc, + createMinimalWorkflowYDoc, +} from './workflowStoreHelpers'; +import { createWorkflowYDoc } from './workflowFactory'; + +describe('setupWorkflowStoreTest', () => { + test('creates store with empty Y.Doc by default', () => { + const { store, ydoc, cleanup } = setupWorkflowStoreTest(); + + // Store should be connected + expect(store.isConnected).toBe(true); + + // Y.Doc should have workflow structure initialized + expect(ydoc.getMap('workflow')).toBeDefined(); + expect(ydoc.getArray('jobs')).toBeDefined(); + expect(ydoc.getArray('triggers')).toBeDefined(); + expect(ydoc.getArray('edges')).toBeDefined(); + expect(ydoc.getMap('positions')).toBeDefined(); + expect(ydoc.getMap('errors')).toBeDefined(); + + // Arrays should be empty + expect(ydoc.getArray('jobs').length).toBe(0); + expect(ydoc.getArray('triggers').length).toBe(0); + expect(ydoc.getArray('edges').length).toBe(0); + + cleanup(); + }); + + test('accepts pre-configured Y.Doc', () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + 'job-b': { + id: 'job-b', + name: 'Job B', + adaptor: '@openfn/language-common', + }, + }, + triggers: { + 'trigger-1': { + id: 'trigger-1', + type: 'webhook', + }, + }, + edges: [ + { + id: 'edge-1', + source: 'trigger-1', + target: 'job-a', + }, + ], + }); + + const { store, ydoc, cleanup } = setupWorkflowStoreTest(customYDoc); + + expect(store.isConnected).toBe(true); + + // Verify Y.Doc has the jobs + expect(ydoc.getArray('jobs').length).toBe(2); + expect(ydoc.getArray('triggers').length).toBe(1); + expect(ydoc.getArray('edges').length).toBe(1); + + // Verify store synced the data + const state = store.getSnapshot(); + expect(state.jobs).toHaveLength(2); + expect(state.triggers).toHaveLength(1); + expect(state.edges).toHaveLength(1); + + cleanup(); + }); + + test('provides mock channel and provider', () => { + const { mockChannel, mockProvider, cleanup } = setupWorkflowStoreTest(); + + expect(mockChannel).toBeDefined(); + expect(mockChannel.push).toBeDefined(); + expect(mockChannel.on).toBeDefined(); + expect(mockChannel.off).toBeDefined(); + + expect(mockProvider).toBeDefined(); + expect(mockProvider.channel).toBe(mockChannel); + + cleanup(); + }); + + test('cleanup disconnects store', () => { + const { store, cleanup } = setupWorkflowStoreTest(); + + expect(store.isConnected).toBe(true); + + cleanup(); + + expect(store.isConnected).toBe(false); + }); +}); + +describe('createEmptyWorkflowYDoc', () => { + test('creates Y.Doc with workflow structure', () => { + const ydoc = createEmptyWorkflowYDoc(); + + expect(ydoc.getMap('workflow')).toBeDefined(); + expect(ydoc.getArray('jobs')).toBeDefined(); + expect(ydoc.getArray('triggers')).toBeDefined(); + expect(ydoc.getArray('edges')).toBeDefined(); + expect(ydoc.getMap('positions')).toBeDefined(); + expect(ydoc.getMap('errors')).toBeDefined(); + + // All should be empty + expect(ydoc.getArray('jobs').length).toBe(0); + expect(ydoc.getArray('triggers').length).toBe(0); + expect(ydoc.getArray('edges').length).toBe(0); + expect(ydoc.getMap('positions').size).toBe(0); + expect(ydoc.getMap('errors').size).toBe(0); + }); +}); + +describe('createMinimalWorkflowYDoc', () => { + test('creates Y.Doc with workflow metadata', () => { + const ydoc = createMinimalWorkflowYDoc('wf-123', 'My Workflow', 5); + + const workflowMap = ydoc.getMap('workflow'); + expect(workflowMap.get('id')).toBe('wf-123'); + expect(workflowMap.get('name')).toBe('My Workflow'); + expect(workflowMap.get('lock_version')).toBe(5); + expect(workflowMap.get('deleted_at')).toBe(null); + expect(workflowMap.get('concurrency')).toBe(null); + expect(workflowMap.get('enable_job_logs')).toBe(false); + }); + + test('uses defaults when no arguments provided', () => { + const ydoc = createMinimalWorkflowYDoc(); + + const workflowMap = ydoc.getMap('workflow'); + expect(workflowMap.get('id')).toBe('workflow-test'); + expect(workflowMap.get('name')).toBe('Test Workflow'); + expect(workflowMap.get('lock_version')).toBe(null); + }); +}); diff --git a/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts new file mode 100644 index 0000000000..87b32a8d42 --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts @@ -0,0 +1,221 @@ +/** + * Workflow Store Test Helpers + * + * Utility functions for testing workflow store functionality. These helpers + * simplify the setup of WorkflowStore instances with Y.Doc and provider + * connections for testing. + * + * Since WorkflowStore is complex and commonly used in tests, these helpers + * consolidate the repetitive Y.Doc + provider initialization logic. + * + * Usage: + * const { store, ydoc, cleanup } = setupWorkflowStoreTest(); + * // ... run test + * cleanup(); + */ + +import * as Y from 'yjs'; +import { vi } from 'vitest'; +import type { Channel } from 'phoenix'; +import type { PhoenixChannelProvider } from 'y-phoenix-channel'; + +import { createWorkflowStore } from '../../../js/collaborative-editor/stores/createWorkflowStore'; +import type { WorkflowStoreInstance } from '../../../js/collaborative-editor/stores/createWorkflowStore'; +import type { Session } from '../../../js/collaborative-editor/types/session'; + +import { + createMockPhoenixChannel, + createMockPhoenixChannelProvider, + type MockPhoenixChannel, + type MockPhoenixChannelProvider, +} from './channelMocks'; + +/** + * Result of setting up a workflow store test + */ +export interface WorkflowStoreTestSetup { + /** The workflow store instance */ + store: WorkflowStoreInstance; + /** The Y.Doc instance (typed as WorkflowDoc) */ + ydoc: Session.WorkflowDoc; + /** Mock Phoenix channel */ + mockChannel: MockPhoenixChannel; + /** Mock channel provider */ + mockProvider: MockPhoenixChannelProvider & { channel: Channel }; + /** Cleanup function to call after test */ + cleanup: () => void; +} + +/** + * Sets up a workflow store test with Y.Doc and provider connection + * + * This helper creates a WorkflowStore instance, initializes a Y.Doc with + * workflow structure, sets up mock channel and provider, and connects them + * together. It provides a consistent starting point for workflow store tests. + * + * The Y.Doc is initialized with the basic workflow structure (workflow map, + * jobs array, triggers array, edges array, positions map, errors map) but + * all are empty. Use the optional `ydoc` parameter to provide a pre-populated + * Y.Doc created with `createWorkflowYDoc()` from workflowFactory. + * + * @param ydoc - Optional pre-configured Y.Doc (defaults to empty workflow) + * @param topic - Optional channel topic (defaults to "test:workflow") + * @returns Test setup with store, ydoc, mocks, and cleanup function + * + * @example + * // Basic usage with empty workflow + * test("workflow store functionality", () => { + * const { store, ydoc, cleanup } = setupWorkflowStoreTest(); + * + * // Y.Doc is already connected, ready to use + * expect(store.isConnected).toBe(true); + * + * cleanup(); + * }); + * + * @example + * // Usage with pre-populated Y.Doc + * import { createWorkflowYDoc } from "./__helpers__"; + * + * test("workflow with jobs", () => { + * const ydoc = createWorkflowYDoc({ + * jobs: { + * "job-a": { id: "job-a", name: "Job A", adaptor: "@openfn/language-common" } + * } + * }); + * + * const { store, cleanup } = setupWorkflowStoreTest(ydoc); + * + * const state = store.getSnapshot(); + * expect(state.jobs).toHaveLength(1); + * + * cleanup(); + * }); + * + * @example + * // Configuring channel responses + * test("workflow save", async () => { + * const { store, mockChannel, cleanup } = setupWorkflowStoreTest(); + * + * // Configure mock channel for save_workflow + * mockChannel.push = vi.fn().mockReturnValue({ + * receive: (status: string, callback: (response?: any) => void) => { + * if (status === "ok") { + * callback({ saved_at: "2025-01-01", lock_version: 1 }); + * } + * return { receive: () => {} }; + * } + * }); + * + * await store.saveWorkflow(); + * + * cleanup(); + * }); + */ +export function setupWorkflowStoreTest( + ydoc?: Y.Doc, + topic: string = 'test:workflow' +): WorkflowStoreTestSetup { + const store = createWorkflowStore(); + + // Create or use provided Y.Doc + const workflowDoc = (ydoc ?? + createEmptyWorkflowYDoc()) as Session.WorkflowDoc; + + // Create mock channel and provider + const mockChannel = createMockPhoenixChannel(topic); + const mockProvider = createMockPhoenixChannelProvider( + mockChannel + ) as MockPhoenixChannelProvider & { channel: Channel }; + + // Attach the Y.Doc to the provider (required by WorkflowStore) + (mockProvider as any).doc = workflowDoc; + + // Connect store to Y.Doc and provider + store.connect(workflowDoc, mockProvider as any); + + return { + store, + ydoc: workflowDoc, + mockChannel, + mockProvider, + cleanup: () => { + store.disconnect(); + }, + }; +} + +/** + * Creates an empty Y.Doc with workflow structure + * + * Initializes a Y.Doc with the expected workflow structure: + * - workflow map (empty) + * - jobs array (empty) + * - triggers array (empty) + * - edges array (empty) + * - positions map (empty) + * - errors map (empty) + * + * This is used internally by setupWorkflowStoreTest when no custom Y.Doc + * is provided. For tests that need pre-populated workflows, use + * `createWorkflowYDoc()` from workflowFactory instead. + * + * @returns Y.Doc with empty workflow structure + * + * @example + * const ydoc = createEmptyWorkflowYDoc(); + * expect(ydoc.getArray("jobs").length).toBe(0); + */ +export function createEmptyWorkflowYDoc(): Y.Doc { + const ydoc = new Y.Doc(); + + // Initialize workflow map (empty) + ydoc.getMap('workflow'); + + // Initialize arrays (empty) + ydoc.getArray('jobs'); + ydoc.getArray('triggers'); + ydoc.getArray('edges'); + + // Initialize positions map (empty) + ydoc.getMap('positions'); + + // Initialize errors map (empty) + ydoc.getMap('errors'); + + return ydoc; +} + +/** + * Creates a minimal workflow Y.Doc with basic workflow metadata + * + * Useful for tests that need a workflow with an ID and name but no jobs, + * triggers, or edges yet. + * + * @param id - Workflow ID (defaults to "workflow-test") + * @param name - Workflow name (defaults to "Test Workflow") + * @param lockVersion - Lock version (defaults to null for new workflow) + * @returns Y.Doc with workflow metadata + * + * @example + * const ydoc = createMinimalWorkflowYDoc("wf-123", "My Workflow"); + * const workflowMap = ydoc.getMap("workflow"); + * expect(workflowMap.get("id")).toBe("wf-123"); + */ +export function createMinimalWorkflowYDoc( + id: string = 'workflow-test', + name: string = 'Test Workflow', + lockVersion: number | null = null +): Y.Doc { + const ydoc = createEmptyWorkflowYDoc(); + + const workflowMap = ydoc.getMap('workflow'); + workflowMap.set('id', id); + workflowMap.set('name', name); + workflowMap.set('lock_version', lockVersion); + workflowMap.set('deleted_at', null); + workflowMap.set('concurrency', null); + workflowMap.set('enable_job_logs', false); + + return ydoc; +} From f24486c3271765d9e79cd2c88c6ec1687f4f1f1a Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 14 Nov 2025 17:14:16 +0200 Subject: [PATCH 04/38] Refactor Header tests to use composable helpers Replace manual test setup (~150 lines per file) with composable helper utilities, improving readability and maintainability: - Reduce setup boilerplate from 50-60 lines to 10-15 lines per file - Use createMinimalWorkflowYDoc() for Y.Doc creation - Use enhanced simulateStoreProviderWithConnection() for complete setup - Fix connection state handling in keyboard tests (isConnected = true) - Eliminate act() warnings by wrapping async emissions and mocking useAdaptorIcons hook Results: - Header.test.tsx: All 21 tests passing, 0 act() warnings - Header.keyboard.test.tsx: All 25 tests passing (14 previously failing due to connection state), 70% reduction in act() warnings (remaining warnings are from react-hotkeys-hook internals) Total: 46/46 tests passing with dramatically improved test clarity. --- .../components/Header.keyboard.test.tsx | 824 ++++++++++++++++++ .../components/Header.test.tsx | 335 +++---- 2 files changed, 1003 insertions(+), 156 deletions(-) create mode 100644 assets/test/collaborative-editor/components/Header.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 new file mode 100644 index 0000000000..003112d959 --- /dev/null +++ b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx @@ -0,0 +1,824 @@ +/** + * Header Keyboard Shortcut Tests + * + * Tests for keyboard shortcuts in the Header component: + * - Cmd+S / Ctrl+S (Save Workflow) + * - Cmd+Shift+S / Ctrl+Shift+S (Save & Sync to GitHub) + * + * Testing approach: + * - Library-agnostic (tests user-facing behavior, not implementation) + * - Platform coverage (Mac Cmd and Windows Ctrl) + * - Guard conditions (canSave, repoConnection) + * - Form field support (enableOnFormTags) + */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HotkeysProvider } from 'react-hotkeys-hook'; +import type React from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { Header } from '../../../js/collaborative-editor/components/Header'; +import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; +import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; +import { simulateStoreProviderWithConnection } from '../__helpers__/storeProviderHelpers'; +import { createMinimalWorkflowYDoc } from '../__helpers__/workflowStoreHelpers'; +import type { CreateSessionContextOptions } from '../__helpers__/sessionContextFactory'; + +// ============================================================================= +// TEST MOCKS +// ============================================================================= + +// Mock useAdaptorIcons to prevent async fetch warnings +vi.mock('../../../js/workflow-diagram/useAdaptorIcons', () => ({ + default: () => ({}), +})); + +// Mock Tooltip to prevent Radix UI timer-based updates +vi.mock('../../../js/collaborative-editor/components/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// ============================================================================= +// TEST HELPERS +// ============================================================================= + +interface WrapperOptions { + permissions?: { can_edit_workflow: boolean; can_run_workflow: boolean }; + latestSnapshotLockVersion?: number; + workflowLockVersion?: number | null; + hasGithubConnection?: boolean; + repoName?: string; + branchName?: string; + workflowDeleted?: boolean; +} + +async function createTestSetup(options: WrapperOptions = {}) { + const { + permissions = { can_edit_workflow: true, can_run_workflow: true }, + latestSnapshotLockVersion = 1, + workflowLockVersion = 1, + hasGithubConnection = false, + repoName = 'openfn/demo', + branchName = 'main', + workflowDeleted = false, + } = options; + + // Create Y.Doc with workflow metadata using helper + const ydoc = createMinimalWorkflowYDoc( + 'test-workflow-123', + 'Test Workflow', + workflowLockVersion + ); + + // Set deleted_at if specified + if (workflowDeleted) { + const workflowMap = ydoc.getMap('workflow'); + workflowMap.set('deleted_at', new Date().toISOString()); + } + + // Build session context options + const sessionContextOptions: CreateSessionContextOptions = { + permissions, + latest_snapshot_lock_version: latestSnapshotLockVersion, + }; + + if (hasGithubConnection) { + sessionContextOptions.project_repo_connection = { + repo: repoName, + branch: branchName, + }; + } + + // Use enhanced helper - THIS HANDLES CONNECTION STATE! + const { stores, sessionStore, cleanup, emitSessionContext } = + await simulateStoreProviderWithConnection( + 'test:room', + { + id: 'user-1', + name: 'Test User', + color: '#ff0000', + }, + { + workflowYDoc: ydoc, + sessionContext: sessionContextOptions, + emitSessionContext: true, + } + ); + + // CRITICAL FIX: Manually emit 'sync' event on provider + // The mock channel doesn't trigger Y.js sync protocol, so provider never emits 'sync' + // We need to manually trigger it so isSynced becomes true + const provider = sessionStore.getProvider(); + if (provider) { + // Emit the 'sync' event with synced=true + (provider as any).emit('sync', [true]); + } + + // Wait a bit for the sync event to propagate + await new Promise(resolve => setTimeout(resolve, 150)); + + // Add spies for keyboard test assertions + const saveWorkflowSpy = vi + .spyOn(stores.workflowStore, 'saveWorkflow') + .mockResolvedValue(undefined); + const openGitHubSyncModalSpy = vi.spyOn( + stores.uiStore, + 'openGitHubSyncModal' + ); + + // Wrapper with HotkeysProvider (keyboard-specific) + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + return { + wrapper, + stores, + sessionStore, + emitSessionContext, + saveWorkflowSpy, + openGitHubSyncModalSpy, + cleanup, + }; +} + +// Helper to render and wait for component to be ready +async function renderAndWaitForReady( + wrapper: React.ComponentType<{ children: React.ReactNode }>, + emitSessionContext: () => void +) { + const result = render( +
+ {[Breadcrumb]} +
, + { wrapper } + ); + + await act(async () => { + emitSessionContext(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + const saveButton = screen.getByTestId('save-workflow-button'); + expect(saveButton).toBeInTheDocument(); + }); + + return result; +} + +// ============================================================================= +// SAVE WORKFLOW KEYBOARD SHORTCUT TESTS (Cmd+S / Ctrl+S) +// ============================================================================= + +describe('Header - Save Workflow (Cmd+S / Ctrl+S)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + // Wait for any pending async updates to settle after test + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + test('Cmd+S calls saveWorkflow when canSave is true (Mac)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + await renderAndWaitForReady(wrapper, emitSessionContext!); + + // Verify the save button is rendered (confirms Header is mounted) + const saveButton = screen.getByTestId('save-workflow-button'); + expect(saveButton).toBeInTheDocument(); + + await user.keyboard('{Meta>}s{/Meta}'); + + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalledTimes(1)); + + cleanup(); + }); + + test('Ctrl+S calls saveWorkflow when canSave is true (Windows)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + await renderAndWaitForReady(wrapper, emitSessionContext!); + + await user.keyboard('{Control>}s{/Control}'); + + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalledTimes(1)); + + cleanup(); + }); + + test('Cmd+S does NOT call saveWorkflow when no edit permission', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: false, can_run_workflow: true }, + }); + + await renderAndWaitForReady(wrapper, emitSessionContext!); + + await user.keyboard('{Meta>}s{/Meta}'); + + // Wait to ensure handler doesn't fire + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + cleanup(); + }); + + test('Cmd+S does NOT call saveWorkflow when workflow is deleted', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + workflowDeleted: true, + }); + + await renderAndWaitForReady(wrapper, emitSessionContext!); + + await user.keyboard('{Meta>}s{/Meta}'); + + // Wait to ensure handler doesn't fire + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + cleanup(); + }); + + test('Cmd+S does NOT call saveWorkflow when viewing old snapshot', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + workflowLockVersion: 1, + latestSnapshotLockVersion: 2, + }); + + await renderAndWaitForReady(wrapper, emitSessionContext!); + + await user.keyboard('{Meta>}s{/Meta}'); + + // Wait to ensure handler doesn't fire + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + cleanup(); + }); + + test('Cmd+S works in input fields (enableOnFormTags)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + render( + <> + +
+ {[Breadcrumb]} +
+ , + { wrapper } + ); + + await act(async () => { + emitSessionContext!(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + expect(screen.getByTestId('save-workflow-button')).toBeInTheDocument(); + }); + + const input = screen.getByTestId('test-input'); + await user.click(input); + + await user.keyboard('{Meta>}s{/Meta}'); + + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalled()); + + cleanup(); + }); + + test('Cmd+S works in textarea (enableOnFormTags)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + render( + <> +