446dd9cfe6
* chore(lint): use Set for LOG_FORMATS membership check
* chore(lint): convert type aliases to interfaces (autofix)
- Remove no-use-before-define rule (conflicts with unistyles ordering)
- Add typescript/consistent-type-definitions: interface
- Run oxlint --fix: 606 type->interface conversions across 268 files
Typecheck green. Warnings: 5432 -> 3787 (-1645).
* chore(lint): clean up relay package warnings
* chore(lint): clean up cli package warnings
* chore(lint): flatten nested ternaries in server (no-nested-ternary)
* chore(lint): escape entities and hoist inline objects in website
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* fix(types): restore Record assignability after type->interface autofix
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): clean up desktop and highlight packages
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): format keyboard-shortcuts-section
* chore(lint): rename shadowed bindings in server (no-shadow)
Rename inner bindings that shadow outer imports or function params:
- Promise executor `resolve` -> `resolvePromise` (shadowed path `resolve`)
- Method params `options` -> `input`/`target`/`update`/`runOptions`/`opts`/`killOptions`
- Misc loop/destructure renames for `workspaceId`, `scriptNames`, `path`, `query`, `taskNotificationItem`
Mechanical change only; no behavior change.
* fix(types): add index signatures for Record assignability
* chore(lint): extract nested callbacks in server (max-nested-callbacks)
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
Work in progress: 30 of 369 warnings fixed across 23 files.
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
* chore(lint): reduce cyclomatic complexity in server
* chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)
* chore(lint): parallelize safe await-in-loop in server
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
Finish eliminating jsx-no-new-function-as-prop warnings in
agent-status-bar.tsx and complete refactor of git-diff-pane.tsx
by extracting per-item components and using stable useCallback
handlers.
* chore(lint): parallelize more safe await-in-loop in server
* fix(server): restore microtask ordering in message dispatch and stream event handler
The complexity refactor added async dispatcher chains that inserted extra
microtasks before message handlers ran, and wrapped the stream event switch
in an await that fired between emitState and dispatchStream. Two tests
regressed on both counts. Route to the matching dispatcher synchronously
and skip the await when the stream handler has no async work.
* chore(lint): hoist/memoize inline arrays and objects in app JSX
* chore(lint): parallelize safe awaits in server (no-await-in-loop)
Converts three sequential `for ... await` loops to `Promise.all`:
- OpenCode: configure MCP servers in parallel
- Sherpa: download model files and ensure multiple models concurrently
- Speech runtime: check required model files across models in parallel
* chore(lint): clear website warnings to zero
Resolves all 61 oxlint warnings in packages/website/ by hoisting inline
callbacks/JSX, extracting sub-components to satisfy jsx-max-depth, using
stable data-derived keys, adding explicit button types, flattening nested
ternaries, and removing unused code.
* chore(lint): parallelize more safe awaits in server (no-await-in-loop)
- Workspace reconciliation: archive missing workspaces and orphaned
projects concurrently instead of sequentially.
- Directory suggestions: resolve child directory candidates in parallel
before filtering.
* chore(lint): allow css imports in import/no-unassigned-import, hoist host page styles
* chore(lint): hoist dictation-controls inline arrays
* fix(server): restore microtask ordering in session message dispatch
Converts per-group dispatchers to Promise<void> | undefined. The complexity
refactor broke two tests by inserting extra microtasks between each
dispatcher's await. The new pattern routes synchronously via a nullish-
coalescing chain and awaits only the matching dispatcher's promise.
* chore(lint): flatten nested ternaries in button/volume-meter/autocomplete
* chore(lint): narrow explicit any in misc server files
* chore(lint): flatten nested ternaries in host-runtime/host-page/providers
* chore(lint): clear desktop warnings to zero
Convert polling loops to recursive helpers to avoid no-await-in-loop,
and switch the ws import to the named export so the WebSocket type is
referenced directly.
* chore(lint): remove useless constructors in server tests
* chore(lint): hoist pair-scan inline arrays and objects
Extracts corner style arrays and barcode scanner settings to module
scope, memoizes insets-dependent body/helper text styles.
* chore(lint): hoist inline style arrays in question-form-card
* chore(lint): hoist/memoize inline styles in plan-card
Extracts markdown rule style arrays into dedicated MarkdownInlineText,
MarkdownListItemContent, and MarkdownParagraph subcomponents that
memoize their own style arrays. Memoizes PlanCard container/title/
description styles.
* chore(lint): narrow explicit any in server relay/loader/logger-likes
* chore(lint): hoist SheetBackground combined style
* chore(lint): hoist inline styles in screen-header
* chore(lint): hoist inline styles/objects in menu-header
* chore(lint): hoist top-level inline arrays in diff-viewer
* chore(lint): narrow explicit any in codex-app-server-agent thread items
* chore(lint): reduce max-depth in claude-sdk-behavior test
* chore(lint): memoize inline styles in command-center
* chore(lint): extract helper to reduce max-depth in agent-response-loop
* chore(lint): flatten nested conditions in pi-direct-agent history
* chore(lint): reduce max-depth in claude-agent query pump
* chore(lint): clear cli warnings to zero
Refactor polling loops into recursive helpers to satisfy
no-await-in-loop, fix WebSocket named import, and correct
an absolute-path import in tests/tmp.
* chore(lint): memoize inline styles in context-menu
* chore(lint): narrow explicit any in claude-agent/openai stt
* chore(lint): reduce max-depth in opencode-agent foreground loop
* chore(lint): memoize inline styles in explorer-sidebar
* chore(lint): memoize mobile sidebar styles in explorer-sidebar
* chore(lint): memoize inline styles in agent-list
* chore(lint): reduce relay test no-await-in-loop warnings
Refactor while-loops and for-retry loops in e2e.test.ts and
live-relay.e2e.test.ts into recursive poll/attempt helpers.
Remaining 8 warnings in encrypted-channel.ts concern the custom
Transport interface (on-handler slots, not DOM EventTarget) and a
sequential send loop; both reflect deliberate runtime contracts.
* chore(lint): fix typecheck errors from hoisting refactors
- pair-scan: type BARCODE_SCANNER_SETTINGS as BarcodeSettings
- explorer-sidebar: add missing desktopSidebarStyle useMemo
- sidebar-workspace-list: coerce null dotColor to transparent
- menu-header, e2e.test: formatting
* chore(lint): memoize inline styles in workspace-screen
* chore(lint): memoize inline styles in workspace-desktop-tabs-row
* chore(lint): hoist constant style arrays in desktop-updates-section
* chore(lint): memoize inline styles in combobox
* chore(lint): extract SplitGroupChild to memoize inline styles
* chore(lint): memoize inline styles in dropdown-menu
* chore(lint): memoize inline styles in workspace-hover-card
* chore(lint): memoize inline styles in autocomplete, message-input, composer
* chore(lint): remove explicit any in server (129 warnings)
- session.ts: catch(error: any) -> catch(error) with Error coercion at use
- daemon-client transport: any -> unknown in listener types
- sherpa/onnx loaders: introduce structural native types
- pocket-tts-onnx: typed ONNX session inputs/outputs/tensors
- tests: any -> unknown + named stub types
* chore(lint): hoist inline arrays and objects in app (145 warnings)
- Hoist static style arrays/objects to module-level consts
- Memoize dynamic ones with useMemo and correct deps
- jsx-no-new-array-as-prop: 145 -> 43
- jsx-no-new-object-as-prop: 64 -> 21
* chore(lint): memoize inline styles in _layout
Fixes 3 react-perf/jsx-no-new-object-as-prop warnings in root layout by
memoizing the stack screen options, the agent screen override, and the
GestureHandlerRootView root style.
* chore(lint): memoize inline styles and objects in stream/status panes
Clears 10 react-perf/jsx-no-new warnings across welcome-screen,
file-explorer-pane, terminal-pane, agent-stream-view, and
agent-status-bar by extracting per-item subcomponents, hoisting constant
style tuples, and memoizing derived arrays/objects.
* chore(lint): memoize inline style objects in list/pane components
Clears 6 react-perf/jsx-no-new-object-as-prop warnings across
synced-loader, stream-strategy-web, sortable-inline-list, file-pane, and
both draggable-list platform variants by memoizing or hoisting their
inline style objects.
* chore(lint): hoist test fixture arrays/objects
Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.
* chore(lint): memoize inline styles in message components
Clears 5 react-perf/jsx-no-new-* warnings in message.tsx by memoizing
image source objects, extracting an AssistantMessageBlockContainer for
per-block spacing, and moving todo list item rendering into a
TodoListItemRow subcomponent so each row memoizes its own style arrays.
The remaining 8 warnings in react-native-markdown-display render rules
are inherent to that library's rule API.
* chore(lint): remove unused vitest imports in server tests
* chore(lint): remove unused helpers and vars in server tests
* chore(lint): add explicit returns in then() callbacks in server
* chore(lint): fix no-shadow and no-nested-ternary in server
* chore(lint): flatten nested blocks to satisfy max-depth in server
* chore(lint): extract helpers to satisfy max-depth in server
* chore(lint): parallelize Claude persisted agents lookup
Replace sequential parseClaudeSessionDescriptor loop with Promise.all
to fix no-await-in-loop warning.
* chore(lint): parallelize Linux watch directory traversal
Switch BFS to level-by-level Promise.all over readdir calls, replacing
the sequential pop/await loop that triggered no-await-in-loop.
* chore(lint): parallelize workspace registry bootstrap upserts
Collect per-workspace upsert inputs in a sync pass, then run registry
writes via Promise.all to eliminate no-await-in-loop warnings.
* chore(lint): parallelize test daemon cleanup rm calls
Run the paseoHomeRoot/staticDir removals concurrently in close() and
the startup catch block to fix no-await-in-loop.
* chore(lint): mechanical cleanup in app (unused, shadow, nested-ternary)
Recovers in-flight edits from the app mechanical agent that couldn't be
pushed due to concurrent tree contention. Removes unused helpers and
locals, flattens nested ternaries, narrows a few props where unused.
* chore(lint): parallelize pending permission approvals
approvePendingPermissions now filters and records handled IDs in a
sync pass, then awaits all allowPermission calls concurrently instead
of looping awaits.
* chore(lint): parallelize rebase head-name lookup
Try both rebase backends (rebase-merge, rebase-apply) concurrently
via Promise.all and return the first non-null result.
* chore(lint): no-unused-vars in app
* chore(lint): parallelize worktree terminal bootstrap
Start each bootstrap terminal concurrently via Promise.all. Since every
spec creates an independent terminal and returns a standalone result,
parallelization preserves order while removing two no-await-in-loop
warnings.
* chore(lint): parallelize worktree bootstrap test cleanup and script spawn
Run terminal cleanup across managers and spawnWorkspaceScript for api
and web concurrently to eliminate no-await-in-loop warnings while
preserving semantics.
* chore(lint): prefer-add-event-listener in app
* chore(lint): parallelize script-health-monitor afterEach and spawns
Close all stub TCP servers concurrently and spawn typecheck/api
scripts via Promise.all to drop two no-await-in-loop warnings.
* chore(lint): jsx-no-useless-fragment in app
* chore(lint): parallelize executable PATH probing
* chore(lint): parallelize client and relay transport test cleanup
* chore(lint): parallelize agent storage record file probing
* chore(lint): no-array-index-key in app
* chore(lint): parallelize bootstrap provider availability cleanup
* chore(lint): parallelize Codex rollout file search
* chore(lint): parallelize dictation wav fixture search
* chore(lint): parallelize Claude session id fixture test
* chore(lint): parallelize script health monitor server cleanup
* chore(lint): parallelize Codex skills directory scan
* chore(lint): promise/always-return in app
* chore(lint): no-shadow in use-dictation, hoist icons in splash screen
* chore(lint): no-shadow in composer callbacks
* chore(format): apply oxfmt to 3 server files
* chore(lint): no-explicit-any in stt-manager/chat-mentions tests
* chore(lint): no-shadow in small app files
Rename inner shadowing identifiers in:
- add-host-modal, agent-status-bar, sidebar-workspace-list, terminal-pane
- workspace-setup-dialog, session-context, use-is-local-daemon
- setup-panel, new-workspace-screen, workspace-desktop-tabs-row
* chore(lint): no-explicit-any in claude-agent.test
* chore(lint): no-shadow in small app test/e2e files
* chore(lint): no-explicit-any in workspace-git-service.test
* chore(lint): no-shadow in app test hoisted blocks
* chore(lint): no-explicit-any in websocket-server.runtime-metrics.test
* chore(lint): no-explicit-any in acp-agent.test
* chore(lint): no-shadow in app composer/message-input/e2e helpers
* chore(lint): no-explicit-any in session.workspace-git-watch.test
* chore(lint): no-explicit-any in websocket-server.notifications.test
* chore(lint): no-shadow in sidebar-workspace-list.test, use-pr-pane-data.test
* chore(lint): no-explicit-any in websocket-server.relay-reconnect.test
* chore(lint): no-explicit-any in codex-app-server-agent.test
* chore(lint): no-shadow in composer.test, host-runtime.test, new-workspace-screen.test
* chore(lint): no-explicit-any in codex-app-server-agent.features.test
* chore(lint): hoist jsx-as-prop in app components batch 1
Memoize inline JSX icons passed as props across add-host-modal,
branch-switcher, composer, pair-link-modal, pr-pane, and
sidebar-workspace-list to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): no-explicit-any in session.test
* chore(lint): hoist jsx-as-prop in app components batch 2
Memoize inline JSX passed as props across agent-list, agent-status-bar,
file-explorer-pane, git-actions-split-button, and message to satisfy
react-perf/jsx-no-jsx-as-prop.
* chore(lint): hoist jsx-as-prop in app components batch 3
Memoize inline JSX passed as props across combined-model-selector,
draggable-list.native, provider-diagnostic-sheet, and
workspace-setup-dialog to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): hoist jsx-as-prop in desktop components
Memoize inline JSX passed as props across desktop-permissions-section,
desktop-updates-section, integrations-section, and pair-device-section
to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): no-explicit-any in mcp-server.test
* chore(lint): hoist jsx-as-prop in workspace/new-workspace screens
* chore(lint): hoist jsx-as-prop in remaining screens
* chore(lint): no-explicit-any in session.workspaces.test
* chore(lint): no-explicit-any in snapshot-mutation-ownership.test
* chore(lint): no-explicit-any in dictation-stream-manager.test
* chore(lint): no-explicit-any in relay-transport.e2e.test
* chore(lint): no-explicit-any in worktree-session.test
* chore(lint): no-explicit-any in model-resolver.test
* chore(lint): no-explicit-any in sherpa-parakeet-stt.test
* chore(lint): no-explicit-any in speech-download.e2e.test
* chore(lint): no-explicit-any in script-health-monitor.test
* chore(lint): no-explicit-any in schedule/service.test
* chore(lint): no-explicit-any in persistence-hooks.test
* chore(lint): no-explicit-any in editor-targets.test
* chore(lint): no-explicit-any in send-while-running-stuck-test-utils.test
* chore(lint): no-explicit-any in agent-storage.test
* chore(lint): no-nested-ternary in app batch 1 (18 files)
* chore(lint): no-explicit-any in generate-sherpa-tts-matrix
* chore(lint): no-explicit-any in claude-agent
* chore(lint): no-explicit-any in websocket-server
* chore(lint): no-explicit-any in process-conversation
* chore(lint): no-explicit-any in daemon-client
* chore(lint): no-explicit-any in codex-app-server-agent
* chore(lint): no-nested-ternary in app batch 2 (26 files)
* chore(format): apply oxfmt to server test files
* chore(lint): parallelize agent archiving in test-mcp-inject
* chore(lint): no-explicit-any in small app files (batch 1)
* chore(lint): no-explicit-any in small app files (batch 2)
* chore(lint): no-explicit-any in app (batch 3)
Covers use-web-scrollbar, stream-strategy, dictation-stream-sender test,
terminal-perf helper, checkout-git-actions-store test, and
web-desktop-scrollbar.
* chore(lint): no-explicit-any in app (batch 4)
Covers composer, message-input, tooltip, workspace-desktop-tabs-row,
and the e2e helpers (app, node-ws-factory, terminal-probes).
* chore(lint): no-explicit-any in polyfills/crypto.ts
* chore(lint): no-explicit-any in components/sidebar-workspace-list.tsx
* chore(lint): prefer-array-find over filter().at/pop
Replace filter(pred).at(-1)/pop() patterns with findLast(pred) and
filter(pred)[0] with find(pred) across server and app.
* chore(lint): no-explicit-any in components/message.tsx
* chore(lint): no-explicit-any in components/plan-card.tsx
* chore(lint): no-map-spread replace with Object.assign
Replace { ...obj, override } inside map callbacks with Object.assign({}, obj, { override })
to satisfy oxc/no-map-spread. Preserves copy-on-write semantics.
* chore(lint): no-extraneous-class in test mocks
Add dispose() stubs to xterm addon mocks to satisfy typescript-eslint/no-extraneous-class, and convert static-only Notification mocks to plain objects.
* chore(lint): no-named-as-default use named imports for ws and openai
* chore(lint): jsx-no-new-array-as-prop hoist composed style in volume-display
Memoize the style array so it is not created inline on every render.
* chore(lint): prefer-add-event-listener use Object.assign for non-DOM handlers
These transports (Transport interface, StreamableHTTPServerTransport) use
plain on<event> properties rather than EventTarget, so addEventListener is
not an option. Using Object.assign to set the handlers avoids the lint
false-positive without changing behavior.
* chore(lint): no-multiple-resolved null pendingResolve after settling
Replace boolean-settled guards with a nullable captured resolve/reject.
Each promise callback captures the resolver, nulls it on first use, and
calls it. This preserves the existing single-resolution semantics while
making the no-multiple-resolved rule happy.
* chore(lint): reduce structural complexity in server
Extract nested-callback cleanup helpers in worktree-bootstrap.test. Extract Codex model definition builder to drop list-models complexity below 20.
* chore(lint): extract helpers in agent-activity and session-store-hooks.test
Split tool-call update handling in groupActivities into helpers to clear max-depth. Hoist useWorkspaceFields selector in the hooks test to drop callback nesting.
* chore(lint): rules-of-hooks and exhaustive-deps in agent-stream-view
Move useMemo calls for permission card styles above the early return
so hooks run unconditionally. Add missing dependencies to the inline
path press handler and the stream render callback.
* chore(lint): rules-of-hooks in diff-scroll via optional context hook
Replace try/catch around useExplorerSidebarAnimation with a new
useExplorerSidebarAnimationOptional hook that returns null when the
provider is absent.
* chore(lint): rules-of-hooks and exhaustive-deps in sidebar-workspace-list
Replace conditional useMemo calls with plain inline arrays, capture
creatingWorkspaceTimeoutsRef.current in the effect body before cleanup,
and depend on the full input object in armTimers.
* chore(lint): rules-of-hooks in split-container
Hoist useWorkspaceLayoutStore and useMemo calls above the pane-kind
early return so hooks always run in the same order.
* chore(lint): rules-of-hooks in context-menu and dropdown-menu
Inline the resolvedWidthStyle object inside the content useMemo and move
the early return below the hook so it is always invoked in the same order.
* chore(lint): rules-of-hooks in web-desktop-scrollbar
Move thumbRegionStyle and handleStyle useMemo calls above the visibility
early return.
* chore(lint): reduce complexity in types/stream
Split reduceStreamUpdate timeline branch into per-case helpers (tool call and compaction) to clear complexity and max-depth warnings.
* chore(lint): reduce complexity in diff-highlighter
Extract metadata detection, path extraction, hunk parsing, and content-line push into helpers to drop parseDiff complexity below 20.
* chore(lint): exhaustive-deps in composer and use-attachment-preview-url
Memoize githubSearchItems so the picker callbacks don't see a new array
every render, hoist realtimeVoiceButtonStyle above rightContent useMemo
that depends on it, and add missing style dependencies. Tighten
use-attachment-preview-url to read the attachment via a ref while keying
off stable field primitives.
* chore(lint): reduce complexity in tool-call-detail-state
Extract per-detail helpers for hasMeaningfulToolCallDetail to clear complexity warning.
* chore(lint): reduce complexity in agent-grouping
Split groupAgents into partition, project-activity map, active-project, and inactive-date helpers.
* chore(lint): reduce max-depth in voice-runtime
Extract retireFinishedGroup helper to flatten processPlaybackQueue nesting.
* chore(lint): reduce max-depth in use-branch-switcher
Extract maybeRestoreStashForBranch callback to flatten handleBranchSelect nesting.
* chore(lint): exhaustive-deps in components batch
Address exhaustive-deps warnings across file-explorer-pane, file-pane,
git-diff-pane, message, provider-diagnostic-sheet, stream-strategy-web,
terminal-emulator, terminal-pane, and volume-meter. Memoize values that
otherwise changed every render, read non-reactive values through refs
when the effect intentionally tracks a different key, and add missing
dependencies where they were genuinely absent.
* chore(lint): reduce complexity/max-depth in draft-store
Extract collectQueuedMessageAttachmentIds and collectStreamUserImageIds helpers from runAttachmentGc. Extract buildMigratedDraftRecord helper for migratePersistedState.
* chore(lint): reduce complexity in tooltip and workspace-scripts-button
Extract resolveActualSide and resolveAlignedCoordinate helpers in tooltip. Extract resolveScriptIconColor in workspace-scripts-button.
* chore(lint): exhaustive-deps in contexts and hooks batch
Fixes exhaustive-deps warnings across app contexts and hooks by adding
missing deps, stabilizing derived arrays with useMemo, capturing ref values
at effect entry, and using ref pattern where deps were intentionally omitted.
* chore(lint): final hook-rules in workspace-screen, editor-button, stores, e2e
Memoizes derived arrays (availableEditors, terminals) to stabilize
useMemo deps, adds missing deps (normalizedServerId, normalizedWorkspaceId,
explorerToggleStyle, workspaceIdsKey, eventName), and renames the Playwright
fixture callback parameter from `use` to `provide` so eslint-plugin-react-hooks
doesn't conflate it with React's use() hook.
* chore(lint): reduce complexity/max-nested-callbacks in workspace-tabs-store
Extract migrate function body into migrateWorkspaceTabsState top-level
helper with per-loop sub-helpers (migrateUiTabsForKey, mergeExplicitTabOrder,
mergeLegacyTabOrder, migrateFocusedTabIds) and replace the inline IIFE in
ensureTab with buildNextTabsForEnsure helper.
* chore(lint): reduce complexity in session-store and panel-store
Extract isSessionServerInfoUnchanged helper from updateSessionServerInfo
in session-store. In panel-store split migrate body into per-version
migration helpers (migratePanelV2Explorer, migratePanelV3Explorer,
migratePanelExplorerTabByCheckout, migratePanelDesktopFocusMode) and
a top-level migratePanelState function.
* chore(lint): reduce complexity in keyboard-shortcuts and use-keyboard-shortcuts
Split resolveKeyboardShortcut into resolveInitialChordStep and
resolveAdvancingChordStep helpers with a shared buildMatchFromBinding
factory. Split handleAction's large switch into handleDispatchOnlyAction,
handlePayloadAction, handleSettingsToggle, and handleCommandCenterToggle
helpers, with hasPayloadKey type guard replacing inline payload checks.
* chore(lint): reduce complexity in audio recorder web hooks
Extract assertMicrophoneEnvironment helper from useAudioRecorder start
callback. Split useDictationAudioSource stop callback with
disconnectDictationAudioGraph, stopMediaRecorderIfActive,
finalizeRecorderStoppedPromise, and safeDisconnectNode helpers.
* chore(lint): reduce complexity in use-git-actions
* chore(lint): reduce complexity in use-pr-pane-data
* chore(lint): reduce complexity in use-agent-autocomplete
* chore(lint): reduce complexity in use-agent-screen-state-machine
* chore(lint): reduce complexity in session-stream-reducers
* chore(lint): reduce complexity in desktop-updates-section
* chore(lint): reduce complexity in pair-device-section
* chore(lint): reduce complexity in update-callout-source
* chore(lint): reduce complexity in provider-diagnostic-sheet
* chore(lint): disable no-await-in-loop (sequential-by-necessity cases)
Most remaining no-await-in-loop warnings were in legitimate sequential
patterns: polling loops with sleep, streaming/pagination cursors, shared
mutable state, ordered side effects (audio playback, port allocation).
Parallelizing these would change observable behavior. Rather than force
restructures that add complexity without benefit, disable the rule.
* chore(lint): reduce complexity in setup-panel
Extract helpers (resolveAutoExpandIndex, resolveSetupStatusLabel,
resolveCommandLog, buildCommandRowState) and lift the standalone log
view and top-level error banner into dedicated sub-components to drop
SetupPanel below the complexity limit.
* chore(lint): reduce complexity in AssistantMarkdownImage
Extract error-text resolution into a helper so the image component
drops below the cyclomatic complexity threshold.
* chore(lint): reduce complexity in ProjectLeadingVisual
Extract the status-variant dispatch into a ProjectLeadingVisualStatus
sub-component so the outer function stays below the cyclomatic
complexity limit.
* chore(lint): extract ChatAgentContent agent-building helper
* chore(lint): reduce complexity in ChatAgentContent
Extract the chat-agent selector (selectChatAgentState +
resolveChatAgentFromSession), the agent-shape constructor
(buildChatAgentFromState), and the not-found/error/boot view
dispatch (renderChatAgentNonReadyView) into helpers so both the
selector callback and the component function drop below the
cyclomatic complexity limit.
* chore(lint): extract subcomponents to reduce JSX nesting depth
* chore(lint): reduce complexity in app components (status-bar, explorer, setup-dialog, tool-call-details)
* chore(lint): reduce complexity in app hooks/runtime/stores/misc components
* chore(lint): long-tail cleanup (no-async-endpoint-handlers, unicorn rules, unescaped-entities, unassigned-import allowlist)
* chore(lint): simplify agent useMemo deps to match helper signature
* chore(lint): rename ChatService.postMessage to dispatchMessage
* chore(lint): server cleanup (no-shadow, no-useless-*, no-unmodified-loop, control-regex, require-yield)
* chore(lint): app cleanup (jsx-max-depth, jsx-no-new-*-as-prop, complexity, misleading-regex, no-useless-*)
* chore(lint): e2e cleanup (no-unmodified-loop-condition, max-nested-callbacks)
* chore(lint): workspace-screen complexity + no-empty-pattern cleanup
* chore(lint): use Array.from for defensive snapshot iteration
* chore(lint): final mechanical tail (control-regex, max-depth, useless-expressions, examples cleanup)
* chore(lint): reduce agent-stream-view complexity
* chore(lint): reduce resolveAgentModelSelection complexity
* chore(lint): reduce globalSetup complexity
* chore(format): oxfmt draft-store single-line signature
* chore(lint): reduce GitDiffPane complexity
* chore(lint): reduce MessageInput complexity
* chore(lint): reduce Combobox complexity
* fix(server): bound listLinuxWatchDirectories readdir concurrency
Wraps the per-level readdir traversal in listLinuxWatchDirectories with a
module-level p-limit (default 16, tunable via PASEO_LINUX_WATCH_READDIR_CONCURRENCY).
On broad repos, the previous Promise.all over an entire BFS level could issue
an unbounded number of concurrent readdir calls. Behavior is otherwise
identical: still traverses all non-.git directories and swallows per-directory
readdir failures.
* chore(lint): promote oxlint warnings to errors
- package.json: lint/lint:fix now pass --deny-warnings so any warning fails
- .github/workflows/ci.yml: add lint job running npm run lint
- .oxlintrc.json: disable no-empty-pattern for e2e/fixtures.ts
(Playwright requires the empty object destructure as the first arg)
- packages/app/e2e/fixtures.ts: restore async ({}, provide) signature
- packages/app/e2e/global-setup.ts: oxfmt single-line signature
* fix(test): relay-transport ws mock exports WebSocket named binding
Commit 38efad7d switched relay-transport.ts from default to named import
of ws.WebSocket, but the test mock still only exported default. Updated
vi.mock("ws") to return both default and named WebSocket bindings so the
mocked module matches the new import shape. Classification: test drifted
with production code; test now exports what the production import needs.
* chore(lint): make oxlint error at config level; add Lefthook pre-commit
Replaces --deny-warnings workaround with semantic severity at the config
level, and adds whole-repo pre-commit gates via Lefthook.
- .oxlintrc.json: all enabled categories (correctness, suspicious, perf)
and intentional rule overrides now use "error" instead of "warn".
Removed duplicate "require-await" key (last-wins "off" preserved).
- package.json: lint scripts are plain oxlint / oxlint --fix. Adds
lefthook devDep and "prepare": "lefthook install --force" so hooks
install on npm install even where core.hooksPath is set globally.
- lefthook.yml: pre-commit runs format:check, lint, typecheck in
parallel. Whole-repo gates (no glob filters). Block-only, no
auto-formatting.
* fix: update lockfile signatures and Nix hash
* chore(lint): pre-commit runs formatter in write mode, then lint+typecheck
Switches the pre-commit hook from format:check to format (write) with
stage_fixed: true, so the formatter rewrites files and re-stages them
into the commit. Lint and typecheck then run in parallel against the
formatted tree.
Sequencing uses Lefthook 2.x jobs with a nested group: top-level jobs
run sequentially, so the format job completes before the checks group
starts; within checks, lint and typecheck run in parallel.
CI continues to use format:check, so unformatted code pushed with
--no-verify still fails on the server.
* chore(lint): revert pre-commit to format:check (no stage_fixed)
stage_fixed: true with a whole-repo formatter (oxfmt .) can silently
re-stage the full reformatted file when a developer used git add -p
to stage only a hunk, losing their hunk-level intent. Safer to just
block the commit on unformatted code and let the dev run
`npm run format` themselves.
Reverts to the simple parallel check form: all three commands run
concurrently and are read-only.
* chore(ts7): tsgo compatibility across the monorepo
Preserves tsc 5.9.3 behavior while making TypeScript 7 / tsgo
(@typescript/native-preview 7.0.0-dev.20260423.1) green
package-by-package.
tsconfig:
- server/tsconfig.server.json: drop removed `baseUrl`; rewrite
`@server/*` paths entry to be relative to the tsconfig.
- website/tsconfig.json: drop removed `baseUrl`; rewrite
`~/*` paths entry to be relative.
- expo-two-way-audio/tsconfig.json: replace inherited
moduleResolution=node10 with `bundler`, set `module: esnext`
(required by `bundler`). Matches Metro/Expo runtime behavior
and avoids forcing `.js` extensions in source.
source:
- app/e2e/helpers/terminal-probes.ts: WebSocket subclass `send`
now types its argument as `Parameters<WebSocket["send"]>[0]`
so the subclass signature tracks the platform type across
tsc 5.9 and TS 7 libdom.
- relay/src/crypto.ts: tweetnacl setPRNG callback allocates a
fresh Uint8Array for getRandomValues (owning ArrayBuffer) and
copies into the caller-provided view. TS 7 libdom requires
ArrayBufferView<ArrayBuffer> (not ArrayBufferLike).
All 8 packages green under both tsc --noEmit and tsgo --noEmit.
* fix(app): update os-notifications test mocks for addEventListener
Commit 46731a51 switched attachWebClickHandler from notification.onclick
to notification.addEventListener("click", ...) to satisfy the
prefer-add-event-listener lint rule. The test mocks still asserted
the old onclick property, so two specs failed on CI with
"notification.addEventListener is not a function". Update the three
MockNotification classes to record click listeners through
addEventListener and drive them via clickListeners[0] in the
assertions.
* chore: switch typecheck from tsc to tsgo (@typescript/native-preview)
Installs @typescript/native-preview 7.0.0-dev.20260423.1 as a root
devDependency and swaps every per-workspace `typecheck` script from
`tsc --noEmit` to `tsgo --noEmit`. Build scripts still use `tsc`, so
emit behavior is unchanged. Monorepo typecheck drops from ~28s to
~4.7s wall clock (~6x faster).
expo-two-way-audio previously ran `tsc` without --noEmit for its
typecheck step. tsgo is stricter about rootDir on emit, so the
typecheck script explicitly passes --noEmit here too.
* fix: update lockfile signatures and Nix hash
* fix(app): commit *.css module declaration for CI typecheck
expo generates expo-env.d.ts (which references expo/types where
*.css is declared) locally, but the file is gitignored. CI does a
fresh checkout + install and doesn't run expo, so the declaration
is missing and tsgo rejects the side-effect import
'@xterm/xterm/css/xterm.css' with TS2882. Add a committed
packages/app/global.d.ts with 'declare module "*.css"' so typecheck
is self-contained.
tsc tolerated the missing declaration more loosely; tsgo is stricter.
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1119 lines
35 KiB
TypeScript
1119 lines
35 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { FileTaskStore } from "./task-store.js";
|
|
|
|
let tempDir: string;
|
|
let store: FileTaskStore;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), "task-store-test-"));
|
|
store = new FileTaskStore(tempDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("create", () => {
|
|
it("creates a task with default status open", async () => {
|
|
const task = await store.create("My first task");
|
|
|
|
expect(task.id).toMatch(/^[a-f0-9]{8}$/);
|
|
expect(task.title).toBe("My first task");
|
|
expect(task.status).toBe("open");
|
|
expect(task.deps).toEqual([]);
|
|
expect(task.parentId).toBeUndefined();
|
|
expect(task.body).toBe("");
|
|
expect(task.notes).toEqual([]);
|
|
expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
expect(task.assignee).toBeUndefined();
|
|
expect(task.priority).toBeUndefined();
|
|
});
|
|
|
|
it("creates a task with priority", async () => {
|
|
const task = await store.create("High priority task", { priority: 1 });
|
|
|
|
expect(task.priority).toBe(1);
|
|
});
|
|
|
|
it("creates a task with custom status", async () => {
|
|
const task = await store.create("Draft task", { status: "draft" });
|
|
|
|
expect(task.status).toBe("draft");
|
|
});
|
|
|
|
it("creates a task with dependencies", async () => {
|
|
const dep1 = await store.create("Dependency 1");
|
|
const dep2 = await store.create("Dependency 2");
|
|
const task = await store.create("Main task", {
|
|
deps: [dep1.id, dep2.id],
|
|
});
|
|
|
|
expect(task.deps).toEqual([dep1.id, dep2.id]);
|
|
});
|
|
|
|
it("creates a task with body", async () => {
|
|
const task = await store.create("Task with body", {
|
|
body: "This is a **long** body\n\nWith multiple lines.",
|
|
});
|
|
|
|
expect(task.body).toBe("This is a **long** body\n\nWith multiple lines.");
|
|
});
|
|
|
|
it("creates a task with parentId", async () => {
|
|
const parent = await store.create("Parent task");
|
|
const child = await store.create("Child task", { parentId: parent.id });
|
|
|
|
expect(child.parentId).toBe(parent.id);
|
|
});
|
|
|
|
it("throws when creating task with non-existent parent", async () => {
|
|
await expect(store.create("Child", { parentId: "nonexistent" })).rejects.toThrow(
|
|
"Parent task not found",
|
|
);
|
|
});
|
|
|
|
it("creates a task with assignee", async () => {
|
|
const task = await store.create("Task for Claude", {
|
|
assignee: "claude",
|
|
});
|
|
|
|
expect(task.assignee).toBe("claude");
|
|
});
|
|
|
|
it("generates unique IDs for each task", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
const task2 = await store.create("Task 2");
|
|
const task3 = await store.create("Task 3");
|
|
|
|
const ids = [task1.id, task2.id, task3.id];
|
|
expect(new Set(ids).size).toBe(3);
|
|
});
|
|
|
|
it("sets created timestamp", async () => {
|
|
const before = new Date().toISOString();
|
|
const task = await store.create("Task");
|
|
const after = new Date().toISOString();
|
|
|
|
expect(task.created >= before).toBe(true);
|
|
expect(task.created <= after).toBe(true);
|
|
});
|
|
|
|
it("returns task with raw content", async () => {
|
|
const task = await store.create("Test task", {
|
|
body: "Task body here",
|
|
acceptanceCriteria: ["criterion 1", "criterion 2"],
|
|
});
|
|
|
|
expect(task.raw).toContain("---");
|
|
expect(task.raw).toContain("title: Test task");
|
|
expect(task.raw).toContain("Task body here");
|
|
expect(task.raw).toContain("## Acceptance Criteria");
|
|
expect(task.raw).toContain("criterion 1");
|
|
expect(task.raw).toContain("criterion 2");
|
|
});
|
|
});
|
|
|
|
describe("get", () => {
|
|
it("returns task by id", async () => {
|
|
const created = await store.create("Test task");
|
|
const retrieved = await store.get(created.id);
|
|
|
|
expect(retrieved).toEqual(created);
|
|
});
|
|
|
|
it("returns null for non-existent task", async () => {
|
|
const result = await store.get("nonexistent");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("preserves assignee field", async () => {
|
|
const created = await store.create("Task", { assignee: "codex" });
|
|
const retrieved = await store.get(created.id);
|
|
|
|
expect(retrieved?.assignee).toBe("codex");
|
|
});
|
|
|
|
it("returns raw content matching the file", async () => {
|
|
const created = await store.create("Task with body", {
|
|
body: "Some body content",
|
|
acceptanceCriteria: ["test passes", "lint passes"],
|
|
});
|
|
await store.addNote(created.id, "A note was added");
|
|
|
|
const retrieved = await store.get(created.id);
|
|
|
|
expect(retrieved?.raw).toContain("title: Task with body");
|
|
expect(retrieved?.raw).toContain("Some body content");
|
|
expect(retrieved?.raw).toContain("## Acceptance Criteria");
|
|
expect(retrieved?.raw).toContain("test passes");
|
|
expect(retrieved?.raw).toContain("lint passes");
|
|
expect(retrieved?.raw).toContain("## Notes");
|
|
expect(retrieved?.raw).toContain("A note was added");
|
|
});
|
|
});
|
|
|
|
describe("list", () => {
|
|
it("returns empty array when no tasks", async () => {
|
|
const tasks = await store.list();
|
|
|
|
expect(tasks).toEqual([]);
|
|
});
|
|
|
|
it("returns all tasks", async () => {
|
|
await store.create("Task 1");
|
|
await store.create("Task 2");
|
|
await store.create("Task 3");
|
|
|
|
const tasks = await store.list();
|
|
|
|
expect(tasks).toHaveLength(3);
|
|
expect(tasks.map((t) => t.title).sort()).toEqual(["Task 1", "Task 2", "Task 3"]);
|
|
});
|
|
});
|
|
|
|
describe("update", () => {
|
|
it("updates task title", async () => {
|
|
const task = await store.create("Original title");
|
|
const updated = await store.update(task.id, { title: "New title" });
|
|
|
|
expect(updated.title).toBe("New title");
|
|
expect(updated.id).toBe(task.id);
|
|
});
|
|
|
|
it("updates task body", async () => {
|
|
const task = await store.create("Task");
|
|
const updated = await store.update(task.id, {
|
|
body: "New body",
|
|
});
|
|
|
|
expect(updated.body).toBe("New body");
|
|
});
|
|
|
|
it("updates task assignee", async () => {
|
|
const task = await store.create("Task");
|
|
const updated = await store.update(task.id, { assignee: "claude" });
|
|
|
|
expect(updated.assignee).toBe("claude");
|
|
});
|
|
|
|
it("persists updates", async () => {
|
|
const task = await store.create("Task");
|
|
await store.update(task.id, { title: "Updated" });
|
|
|
|
const retrieved = await store.get(task.id);
|
|
expect(retrieved?.title).toBe("Updated");
|
|
});
|
|
|
|
it("preserves created timestamp on update", async () => {
|
|
const task = await store.create("Task");
|
|
const originalCreated = task.created;
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await store.update(task.id, { title: "Updated" });
|
|
|
|
const retrieved = await store.get(task.id);
|
|
expect(retrieved?.created).toBe(originalCreated);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(store.update("nonexistent", { title: "New" })).rejects.toThrow();
|
|
});
|
|
|
|
it("replaces acceptance criteria", async () => {
|
|
const task = await store.create("Task", {
|
|
acceptanceCriteria: ["old criterion 1", "old criterion 2"],
|
|
});
|
|
|
|
const updated = await store.update(task.id, {
|
|
acceptanceCriteria: ["new criterion"],
|
|
});
|
|
|
|
expect(updated.acceptanceCriteria).toEqual(["new criterion"]);
|
|
});
|
|
|
|
it("clears acceptance criteria with empty array", async () => {
|
|
const task = await store.create("Task", {
|
|
acceptanceCriteria: ["criterion 1", "criterion 2"],
|
|
});
|
|
|
|
const updated = await store.update(task.id, {
|
|
acceptanceCriteria: [],
|
|
});
|
|
|
|
expect(updated.acceptanceCriteria).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("delete", () => {
|
|
it("deletes a task", async () => {
|
|
const task = await store.create("Task to delete");
|
|
await store.delete(task.id);
|
|
|
|
const retrieved = await store.get(task.id);
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
|
|
it("removes task from list", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
const task2 = await store.create("Task 2");
|
|
await store.delete(task1.id);
|
|
|
|
const tasks = await store.list();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].id).toBe(task2.id);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(store.delete("nonexistent")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("status transitions", () => {
|
|
it("transitions draft to open", async () => {
|
|
const task = await store.create("Draft", { status: "draft" });
|
|
await store.open(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("open");
|
|
});
|
|
|
|
it("reopens a done task", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
await store.open(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("open");
|
|
});
|
|
|
|
it("reopens a failed task", async () => {
|
|
const task = await store.create("Task");
|
|
await store.fail(task.id);
|
|
await store.open(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("open");
|
|
});
|
|
|
|
it("reopens an in_progress task", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
await store.open(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("open");
|
|
});
|
|
|
|
it("is idempotent for already open task", async () => {
|
|
const task = await store.create("Task");
|
|
await store.open(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("open");
|
|
});
|
|
|
|
it("transitions open to in_progress", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("in_progress");
|
|
});
|
|
|
|
it("throws when task is draft", async () => {
|
|
const task = await store.create("Draft", { status: "draft" });
|
|
|
|
await expect(store.start(task.id)).rejects.toThrow();
|
|
});
|
|
|
|
it("throws when task is already done", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
await expect(store.start(task.id)).rejects.toThrow();
|
|
});
|
|
|
|
it("transitions open to done", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("done");
|
|
});
|
|
|
|
it("transitions in_progress to done", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
await store.close(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("done");
|
|
});
|
|
|
|
it("transitions draft to done", async () => {
|
|
const task = await store.create("Task", { status: "draft" });
|
|
await store.close(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("done");
|
|
});
|
|
});
|
|
|
|
describe("dependencies", () => {
|
|
it("adds a dependency", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task");
|
|
|
|
await store.addDep(task.id, dep.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toContain(dep.id);
|
|
});
|
|
|
|
it("does not duplicate dependencies", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task");
|
|
|
|
await store.addDep(task.id, dep.id);
|
|
await store.addDep(task.id, dep.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toEqual([dep.id]);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
const dep = await store.create("Dependency");
|
|
|
|
await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow();
|
|
});
|
|
|
|
it("throws for non-existent dependency", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow();
|
|
});
|
|
|
|
it("removes a dependency", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task", { deps: [dep.id] });
|
|
|
|
await store.removeDep(task.id, dep.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toEqual([]);
|
|
});
|
|
|
|
it("is idempotent for non-existent dep", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await store.removeDep(task.id, "nonexistent");
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("notes", () => {
|
|
it("adds a note with timestamp", async () => {
|
|
const task = await store.create("Task");
|
|
const before = new Date().toISOString();
|
|
|
|
await store.addNote(task.id, "This is a note");
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.notes).toHaveLength(1);
|
|
expect(updated?.notes[0].content).toBe("This is a note");
|
|
expect(updated?.notes[0].timestamp >= before).toBe(true);
|
|
});
|
|
|
|
it("appends multiple notes in order", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await store.addNote(task.id, "First note");
|
|
await store.addNote(task.id, "Second note");
|
|
await store.addNote(task.id, "Third note");
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.notes).toHaveLength(3);
|
|
expect(updated?.notes.map((n) => n.content)).toEqual([
|
|
"First note",
|
|
"Second note",
|
|
"Third note",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("getReady", () => {
|
|
it("returns open tasks with no deps", async () => {
|
|
const task = await store.create("Ready task");
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("excludes draft tasks", async () => {
|
|
await store.create("Draft task", { status: "draft" });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes in_progress tasks", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes done tasks", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes tasks with unresolved deps", async () => {
|
|
const dep = await store.create("Dependency");
|
|
await store.create("Blocked task", { deps: [dep.id] });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(dep.id);
|
|
});
|
|
|
|
it("includes tasks when all deps are done", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task", { deps: [dep.id] });
|
|
await store.close(dep.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("handles multiple deps correctly", async () => {
|
|
const dep1 = await store.create("Dep 1");
|
|
const dep2 = await store.create("Dep 2");
|
|
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
|
|
|
|
// Only one dep done - task not ready
|
|
await store.close(dep1.id);
|
|
let ready = await store.getReady();
|
|
expect(ready.map((t) => t.id)).not.toContain(task.id);
|
|
|
|
// Both deps done - task ready
|
|
await store.close(dep2.id);
|
|
ready = await store.getReady();
|
|
expect(ready.map((t) => t.id)).toContain(task.id);
|
|
});
|
|
|
|
it("sorts by created date (oldest first) when no priority", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task2 = await store.create("Task 2");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task3 = await store.create("Task 3");
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]);
|
|
});
|
|
|
|
it("sorts by priority first (lower number = higher priority)", async () => {
|
|
// Create in wrong order to prove priority wins over creation time
|
|
const low = await store.create("Low priority", { priority: 10 });
|
|
const high = await store.create("High priority", { priority: 1 });
|
|
const medium = await store.create("Medium priority", { priority: 5 });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready.map((t) => t.id)).toEqual([high.id, medium.id, low.id]);
|
|
});
|
|
|
|
it("tasks with priority come before tasks without", async () => {
|
|
// Create without priority first to prove priority wins
|
|
const noPriority = await store.create("No priority");
|
|
const withPriority = await store.create("With priority", { priority: 5 });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready.map((t) => t.id)).toEqual([withPriority.id, noPriority.id]);
|
|
});
|
|
|
|
it("sorts by created date within same priority", async () => {
|
|
const first = await store.create("First", { priority: 1 });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const second = await store.create("Second", { priority: 1 });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const third = await store.create("Third", { priority: 1 });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready.map((t) => t.id)).toEqual([first.id, second.id, third.id]);
|
|
});
|
|
|
|
it("excludes parent task when it has open children", async () => {
|
|
const parent = await store.create("Parent task");
|
|
await store.create("Child 1", { parentId: parent.id });
|
|
await store.create("Child 2", { parentId: parent.id });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
// Children are ready but parent is not (has open children)
|
|
expect(ready).toHaveLength(2);
|
|
expect(ready.map((t) => t.id)).not.toContain(parent.id);
|
|
});
|
|
|
|
it("includes parent task when all children are done", async () => {
|
|
const parent = await store.create("Parent task");
|
|
const child1 = await store.create("Child 1", { parentId: parent.id });
|
|
const child2 = await store.create("Child 2", { parentId: parent.id });
|
|
|
|
await store.close(child1.id);
|
|
await store.close(child2.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(parent.id);
|
|
});
|
|
|
|
it("excludes parent when some children are not done", async () => {
|
|
const parent = await store.create("Parent task");
|
|
const child1 = await store.create("Child 1", { parentId: parent.id });
|
|
await store.create("Child 2", { parentId: parent.id });
|
|
|
|
await store.close(child1.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
// Only child2 is ready, parent is blocked by child2
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready.map((t) => t.id)).not.toContain(parent.id);
|
|
});
|
|
|
|
it("handles nested children - parent waits for all descendants", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
const grandchild = await store.create("Grandchild", {
|
|
parentId: child.id,
|
|
});
|
|
|
|
// Only grandchild is ready (no children)
|
|
let ready = await store.getReady();
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(grandchild.id);
|
|
|
|
// After grandchild done, child becomes ready
|
|
await store.close(grandchild.id);
|
|
ready = await store.getReady();
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(child.id);
|
|
|
|
// After child done, parent becomes ready
|
|
await store.close(child.id);
|
|
ready = await store.getReady();
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(parent.id);
|
|
});
|
|
|
|
it("combines deps and children blocking", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const parent = await store.create("Parent", { deps: [dep.id] });
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
// Only dep and child are ready (parent blocked by both dep and child)
|
|
let ready = await store.getReady();
|
|
expect(ready).toHaveLength(2);
|
|
expect(ready.map((t) => t.id)).toContain(dep.id);
|
|
expect(ready.map((t) => t.id)).toContain(child.id);
|
|
expect(ready.map((t) => t.id)).not.toContain(parent.id);
|
|
|
|
// Close child but not dep - parent still blocked
|
|
await store.close(child.id);
|
|
ready = await store.getReady();
|
|
expect(ready.map((t) => t.id)).not.toContain(parent.id);
|
|
|
|
// Close dep - now parent is ready
|
|
await store.close(dep.id);
|
|
ready = await store.getReady();
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(parent.id);
|
|
});
|
|
|
|
it("task with no children is ready (leaf task)", async () => {
|
|
const task = await store.create("Leaf task");
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("returns only ready tasks in epic children tree", async () => {
|
|
await store.create("Unrelated task");
|
|
const epic = await store.create("Epic");
|
|
const child = await store.create("Epic child", { parentId: epic.id });
|
|
|
|
const ready = await store.getReady(epic.id);
|
|
|
|
// Only child is ready (epic blocked by its open child)
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(child.id);
|
|
|
|
// After closing child, epic becomes ready
|
|
await store.close(child.id);
|
|
const readyAfter = await store.getReady(epic.id);
|
|
expect(readyAfter).toHaveLength(1);
|
|
expect(readyAfter[0].id).toBe(epic.id);
|
|
});
|
|
|
|
it("returns empty when epic and children are not ready", async () => {
|
|
const epic = await store.create("Epic", { status: "draft" });
|
|
await store.create("Child", { parentId: epic.id, status: "draft" });
|
|
|
|
const ready = await store.getReady(epic.id);
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("handles nested children", async () => {
|
|
const epic = await store.create("Epic");
|
|
const child = await store.create("Child", { parentId: epic.id });
|
|
const grandchild = await store.create("Grandchild", {
|
|
parentId: child.id,
|
|
});
|
|
|
|
// Only grandchild is ready (leaf node)
|
|
let ready = await store.getReady(epic.id);
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(grandchild.id);
|
|
|
|
// After closing grandchild, child becomes ready
|
|
await store.close(grandchild.id);
|
|
ready = await store.getReady(epic.id);
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(child.id);
|
|
|
|
// After closing child, epic becomes ready
|
|
await store.close(child.id);
|
|
ready = await store.getReady(epic.id);
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(epic.id);
|
|
});
|
|
});
|
|
|
|
describe("getBlocked", () => {
|
|
it("returns tasks with unresolved deps", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const blocked = await store.create("Blocked", { deps: [dep.id] });
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe(blocked.id);
|
|
});
|
|
|
|
it("excludes tasks with no deps", async () => {
|
|
await store.create("No deps");
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes tasks with all deps done", async () => {
|
|
const dep = await store.create("Dep");
|
|
await store.create("Task", { deps: [dep.id] });
|
|
await store.close(dep.id);
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes draft tasks", async () => {
|
|
const dep = await store.create("Dep");
|
|
await store.create("Draft blocked", { status: "draft", deps: [dep.id] });
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("includes in_progress tasks with unresolved deps", async () => {
|
|
const dep = await store.create("Dep");
|
|
const task = await store.create("Task", { deps: [dep.id] });
|
|
// Force start even with unresolved deps (edge case)
|
|
await store.update(task.id, { status: "in_progress" });
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("returns only blocked tasks in epic children tree", async () => {
|
|
const unrelatedDep = await store.create("Unrelated dep");
|
|
await store.create("Unrelated blocked", {
|
|
deps: [unrelatedDep.id],
|
|
});
|
|
|
|
const epic = await store.create("Epic");
|
|
const externalDep = await store.create("External dep");
|
|
const blockedChild = await store.create("Blocked child", {
|
|
parentId: epic.id,
|
|
deps: [externalDep.id],
|
|
});
|
|
|
|
const blocked = await store.getBlocked(epic.id);
|
|
|
|
expect(blocked).toHaveLength(1);
|
|
expect(blocked[0].id).toBe(blockedChild.id);
|
|
});
|
|
});
|
|
|
|
describe("getClosed", () => {
|
|
it("returns done tasks", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
const closed = await store.getClosed();
|
|
|
|
expect(closed).toHaveLength(1);
|
|
expect(closed[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("excludes non-done tasks", async () => {
|
|
await store.create("Open task");
|
|
await store.create("Draft task", { status: "draft" });
|
|
const inProgress = await store.create("In progress");
|
|
await store.start(inProgress.id);
|
|
|
|
const closed = await store.getClosed();
|
|
|
|
expect(closed).toHaveLength(0);
|
|
});
|
|
|
|
it("sorts by created date (most recent first)", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task2 = await store.create("Task 2");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task3 = await store.create("Task 3");
|
|
|
|
await store.close(task1.id);
|
|
await store.close(task2.id);
|
|
await store.close(task3.id);
|
|
|
|
const closed = await store.getClosed();
|
|
|
|
expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]);
|
|
});
|
|
|
|
it("returns only closed tasks in epic children tree", async () => {
|
|
const unrelated = await store.create("Unrelated");
|
|
await store.close(unrelated.id);
|
|
|
|
const epic = await store.create("Epic");
|
|
const child = await store.create("Epic child", { parentId: epic.id });
|
|
await store.close(child.id);
|
|
|
|
const closed = await store.getClosed(epic.id);
|
|
|
|
// Only the closed child is returned (epic is still open)
|
|
expect(closed).toHaveLength(1);
|
|
expect(closed[0].id).toBe(child.id);
|
|
});
|
|
});
|
|
|
|
describe("getDepTree", () => {
|
|
it("returns empty for task with no deps", async () => {
|
|
const task = await store.create("Leaf task");
|
|
|
|
const tree = await store.getDepTree(task.id);
|
|
|
|
expect(tree).toEqual([]);
|
|
});
|
|
|
|
it("returns direct deps", async () => {
|
|
const dep1 = await store.create("Dep 1");
|
|
const dep2 = await store.create("Dep 2");
|
|
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
|
|
|
|
const tree = await store.getDepTree(task.id);
|
|
|
|
expect(tree).toHaveLength(2);
|
|
expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort());
|
|
});
|
|
|
|
it("returns nested deps recursively", async () => {
|
|
const leaf = await store.create("Leaf");
|
|
const middle = await store.create("Middle", { deps: [leaf.id] });
|
|
const root = await store.create("Root", { deps: [middle.id] });
|
|
|
|
const tree = await store.getDepTree(root.id);
|
|
|
|
expect(tree).toHaveLength(2);
|
|
expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort());
|
|
});
|
|
|
|
it("handles diamond deps without duplicates", async () => {
|
|
const shared = await store.create("Shared");
|
|
const left = await store.create("Left", { deps: [shared.id] });
|
|
const right = await store.create("Right", { deps: [shared.id] });
|
|
const root = await store.create("Root", { deps: [left.id, right.id] });
|
|
|
|
const tree = await store.getDepTree(root.id);
|
|
|
|
expect(tree).toHaveLength(3);
|
|
expect(tree.map((t) => t.id).sort()).toEqual([shared.id, left.id, right.id].sort());
|
|
});
|
|
|
|
it("handles circular deps gracefully", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
const task2 = await store.create("Task 2", { deps: [task1.id] });
|
|
await store.addDep(task1.id, task2.id); // create cycle
|
|
|
|
// Should not infinite loop
|
|
const tree = await store.getDepTree(task1.id);
|
|
|
|
expect(tree.map((t) => t.id)).toContain(task2.id);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(store.getDepTree("nonexistent")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("file persistence", () => {
|
|
it("persists tasks across store instances", async () => {
|
|
const task = await store.create("Persistent task", {
|
|
body: "With body",
|
|
assignee: "claude",
|
|
});
|
|
await store.addNote(task.id, "A note");
|
|
|
|
// Create new store instance pointing to same dir
|
|
const store2 = new FileTaskStore(tempDir);
|
|
const retrieved = await store2.get(task.id);
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.title).toBe("Persistent task");
|
|
expect(retrieved?.body).toBe("With body");
|
|
expect(retrieved?.assignee).toBe("claude");
|
|
expect(retrieved?.created).toBe(task.created);
|
|
expect(retrieved?.notes).toHaveLength(1);
|
|
expect(retrieved?.notes[0].content).toBe("A note");
|
|
});
|
|
|
|
it("persists parentId across store instances", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
const store2 = new FileTaskStore(tempDir);
|
|
const retrieved = await store2.get(child.id);
|
|
|
|
expect(retrieved?.parentId).toBe(parent.id);
|
|
});
|
|
|
|
it("persists priority across store instances", async () => {
|
|
const task = await store.create("Priority task", { priority: 3 });
|
|
|
|
const store2 = new FileTaskStore(tempDir);
|
|
const retrieved = await store2.get(task.id);
|
|
|
|
expect(retrieved?.priority).toBe(3);
|
|
});
|
|
|
|
it("persists raw content across store instances", async () => {
|
|
const task = await store.create("Task with everything", {
|
|
body: "Detailed body",
|
|
acceptanceCriteria: ["tests pass", "build succeeds"],
|
|
assignee: "claude",
|
|
priority: 1,
|
|
});
|
|
await store.addNote(task.id, "Implementation note");
|
|
|
|
const store2 = new FileTaskStore(tempDir);
|
|
const retrieved = await store2.get(task.id);
|
|
|
|
expect(retrieved?.raw).toContain("title: Task with everything");
|
|
expect(retrieved?.raw).toContain("Detailed body");
|
|
expect(retrieved?.raw).toContain("## Acceptance Criteria");
|
|
expect(retrieved?.raw).toContain("tests pass");
|
|
expect(retrieved?.raw).toContain("build succeeds");
|
|
expect(retrieved?.raw).toContain("## Notes");
|
|
expect(retrieved?.raw).toContain("Implementation note");
|
|
expect(retrieved?.raw).toContain("assignee: claude");
|
|
expect(retrieved?.raw).toContain("priority: 1");
|
|
});
|
|
});
|
|
|
|
describe("parent-child hierarchy", () => {
|
|
it("returns empty array for task with no parent", async () => {
|
|
const task = await store.create("Root task");
|
|
|
|
const ancestors = await store.getAncestors(task.id);
|
|
|
|
expect(ancestors).toEqual([]);
|
|
});
|
|
|
|
it("returns parent for child task", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
const ancestors = await store.getAncestors(child.id);
|
|
|
|
expect(ancestors).toHaveLength(1);
|
|
expect(ancestors[0].id).toBe(parent.id);
|
|
});
|
|
|
|
it("returns full ancestor chain in order", async () => {
|
|
const grandparent = await store.create("Grandparent");
|
|
const parent = await store.create("Parent", {
|
|
parentId: grandparent.id,
|
|
});
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
const ancestors = await store.getAncestors(child.id);
|
|
|
|
expect(ancestors).toHaveLength(2);
|
|
expect(ancestors[0].id).toBe(parent.id);
|
|
expect(ancestors[1].id).toBe(grandparent.id);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(store.getAncestors("nonexistent")).rejects.toThrow();
|
|
});
|
|
|
|
it("returns empty array for task with no children", async () => {
|
|
const task = await store.create("Leaf task");
|
|
|
|
const children = await store.getChildren(task.id);
|
|
|
|
expect(children).toEqual([]);
|
|
});
|
|
|
|
it("returns direct children", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child1 = await store.create("Child 1", { parentId: parent.id });
|
|
const child2 = await store.create("Child 2", { parentId: parent.id });
|
|
|
|
const children = await store.getChildren(parent.id);
|
|
|
|
expect(children).toHaveLength(2);
|
|
expect(children.map((c) => c.id).sort()).toEqual([child1.id, child2.id].sort());
|
|
});
|
|
|
|
it("does not return grandchildren", async () => {
|
|
const grandparent = await store.create("Grandparent");
|
|
const parent = await store.create("Parent", {
|
|
parentId: grandparent.id,
|
|
});
|
|
await store.create("Grandchild", { parentId: parent.id });
|
|
|
|
const children = await store.getChildren(grandparent.id);
|
|
|
|
expect(children).toHaveLength(1);
|
|
expect(children[0].id).toBe(parent.id);
|
|
});
|
|
|
|
it("sorts by priority first (lower number = higher priority)", async () => {
|
|
const parent = await store.create("Parent");
|
|
// Create in wrong order to prove priority wins over creation time
|
|
const low = await store.create("Low priority child", {
|
|
parentId: parent.id,
|
|
priority: 10,
|
|
});
|
|
const high = await store.create("High priority child", {
|
|
parentId: parent.id,
|
|
priority: 1,
|
|
});
|
|
const medium = await store.create("Medium priority child", {
|
|
parentId: parent.id,
|
|
priority: 5,
|
|
});
|
|
|
|
const children = await store.getChildren(parent.id);
|
|
|
|
expect(children.map((c) => c.id)).toEqual([high.id, medium.id, low.id]);
|
|
});
|
|
|
|
it("children with priority come before children without", async () => {
|
|
const parent = await store.create("Parent");
|
|
// Create without priority first to prove priority wins
|
|
const noPriority = await store.create("No priority", {
|
|
parentId: parent.id,
|
|
});
|
|
const withPriority = await store.create("With priority", {
|
|
parentId: parent.id,
|
|
priority: 5,
|
|
});
|
|
|
|
const children = await store.getChildren(parent.id);
|
|
|
|
expect(children.map((c) => c.id)).toEqual([withPriority.id, noPriority.id]);
|
|
});
|
|
|
|
it("sets parent on a task", async () => {
|
|
const parent = await store.create("Parent");
|
|
const task = await store.create("Task");
|
|
|
|
await store.setParent(task.id, parent.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.parentId).toBe(parent.id);
|
|
});
|
|
|
|
it("removes parent when set to null", async () => {
|
|
const parent = await store.create("Parent");
|
|
const task = await store.create("Task", { parentId: parent.id });
|
|
|
|
await store.setParent(task.id, null);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.parentId).toBeUndefined();
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
const parent = await store.create("Parent");
|
|
|
|
await expect(store.setParent("nonexistent", parent.id)).rejects.toThrow("Task not found");
|
|
});
|
|
|
|
it("throws for non-existent parent", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await expect(store.setParent(task.id, "nonexistent")).rejects.toThrow("Parent task not found");
|
|
});
|
|
|
|
it("throws when setting task as its own parent", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await expect(store.setParent(task.id, task.id)).rejects.toThrow(
|
|
"Task cannot be its own parent",
|
|
);
|
|
});
|
|
|
|
it("throws when creating circular reference", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
await expect(store.setParent(parent.id, child.id)).rejects.toThrow("circular reference");
|
|
});
|
|
});
|