diff --git a/CHANGELOG.md b/CHANGELOG.md index b421496a8..ed8b7e5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. ### Added +- **Canonical user-story tracker**: Inventory every chrome and workspace feature in `docs/qa/user-stories.csv` with expected behavior derived from the current code, plus fixture/RTL coverage for those surfaces. + - **Administrator restart with source restoration (#384)**: Application-wide restart as administrator on supported Windows builds, restoring open sources after elevation. - **Company Portal macOS unified-log evidence (#390)**: Normalize Apple unified-log evidence for Company Portal on macOS so enrollment and portal diagnostics correlate without inventing outcomes. - **Intune Device Inventory Agent log family (#397 / #354)**: Discover and parse the full Microsoft Device Inventory Agent log set under Program Files (harvester, Inventory Adaptor, and rotation-failure dialects) with known-sources entry, folder aggregation for `.log` / rotations / `.log_`, and logical-record-aware real-time tailing. @@ -29,6 +31,11 @@ All notable changes to this project will be documented in this file. ### Fixed +- **Dialog landmarks**: Filter, Collect Diagnostics, Collection Complete, Update, and first-run file-association overlays expose `role="dialog"` / `aria-modal` so they are reachable as dialog landmarks. +- **Tab switch restores the selected log**: Cached tab switches apply the file snapshot before the sidebar folder listing, so swapping tabs no longer leaves the previous file on screen while the listing is in flight. +- **Timeline File > Open Folder IME sources**: Opening an IME log folder from File > Open now also adds the folder path so `extract_ime_events` can run, matching File > New Timeline from Folder. Empty folders are a no-op instead of a zero-event IntuneEvents source. +- **dsregcmd drag-and-drop**: Dropping a dsregcmd evidence path again analyzes the file with folder fallback and records Recent, instead of treating an uninspectable path as a generic file source. + - **Unicode decimal digit panics (#413 / #502)**: Reject non-ASCII Unicode decimal fields in CCM and related time grammars so multi-byte digits cannot panic the parser mid-slice. - **Signless CCM timestamp display (#410 / #504)**: Treat signless fractional tails as milliseconds (not fabricated timezone offsets); short fractions pad correctly for public `LogEntry` projection. - **IPv6 redaction residual (#416 / #503)**: Redact bare unspecified IPv6 forms in macOS export paths without destroying C++ `std::` symbols. diff --git a/docs/qa/user-stories.csv b/docs/qa/user-stories.csv new file mode 100644 index 000000000..2b0b8aaed --- /dev/null +++ b/docs/qa/user-stories.csv @@ -0,0 +1,119 @@ +id,area,title,user_story,expected_behavior,entry_points,source_files,platforms,edition,notes,status,phase,error_class,error_detail,test_method,tested_at,retest_status,retest_at,fix_notes +CHROME-001,chrome,Open file from File menu or Ctrl+O,As an analyst I want to open a log file so I can inspect it in the active workspace.,File > Open File or Ctrl/Cmd+O opens a native file dialog using the active workspace fileFilters and actionLabels.file. Selected path is handed to the workspace onOpenSource or the generic log loader.,File menu; Ctrl/Cmd+O,src-tauri/src/menu.rs; src/hooks/use-app-actions.ts; src/hooks/use-keyboard.ts,all,both,Toolbar Open menu shows Open file... (native dialog not invoked).,pass,test,none,,ui,2026-08-18T14:22:48Z,,, +CHROME-002,chrome,Open folder from File menu,As an analyst I want to open a folder of logs so sibling files load as a source.,File > Open Folder opens a directory picker. Folder is listed in the sidebar and parsed according to the active workspace.,File menu; Toolbar Open menu,src-tauri/src/menu.rs; src/hooks/use-app-actions.ts; src/components/layout/Toolbar.tsx,all,both,Toolbar Open menu shows Open folder... (native dialog not invoked).,pass,test,none,,ui,2026-08-18T14:22:48Z,,, +CHROME-003,chrome,Open known log sources,As an analyst I want catalogued Intune/CM paths so I do not hunt for default locations.,File > Known Sources and the toolbar known-source menu list families and sources from get_known_log_sources. Unavailable sources are disabled. Selecting one loads that path and clears the filter.,File > Known Sources; Toolbar known-source menu,src-tauri/src/menu.rs; src/components/layout/Toolbar.tsx; src/lib/log-source.ts,all,both,"Disabled when workspace.capabilities.knownSources is false (dsregcmd, secureboot, sccm). event-log only sets sidebar: false, so the known-source menu stays enabled. | Known sources button disabled when catalog empty.",pass,test,none,,ui,2026-08-18T14:22:48Z,,, +CHROME-004,chrome,Open recent files and clear recents,As an analyst I want recent files so I can resume the last case.,File > Recent lists persisted recents. Selecting one reopens that path in the recorded workspace. Clear Recent empties the list.,File > Recent; File > Clear Recent,src-tauri/src/menu.rs; src/lib/recent-entries.ts; src-tauri/src/commands/recent_entries.rs,all,both,recent-entries + menu reopen/clear tests.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-005,chrome,Save and restore a session,As an analyst I want to save open tabs and filters so I can resume later.,"File > Save Session writes a .cmtrace file. File > Open Session restores tabs, workspace, and filter state from that file.",File > Save Session (Shift+Cmd/Ctrl+S); File > Open Session,src/lib/session-save.ts; src/lib/session-restore.ts; src-tauri/src/menu.rs,all,both,session-save/restore unit tests.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-006,chrome,Restart as administrator,As a Windows analyst I want to relaunch elevated so protected sources become readable.,File > Restart as Administrator is Windows-only and disabled when already elevated. It opens RestartAsAdministratorDialog. Confirm calls restart_as_administrator and leaves the dialog pending until launch; cancel/Esc closes without relaunch.,File > Restart as Administrator; Access Denied recovery,src-tauri/src/menu.rs; src/components/dialogs/RestartAsAdministratorDialog.tsx; src/lib/elevation.ts,windows,both,Restart-as-admin dialog + elevation helpers (vitest).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-007,chrome,"Find, find next, find previous",As an analyst I want incremental search so I can jump between matching lines.,"Ctrl/Cmd+F or Edit > Find opens FindBar when the workspace has findBar. Match case and regex toggles. Enter/F3 next, Shift+Enter/Shift+F3 previous. Esc closes. Invalid regex shows an error; no matches show No results.",Ctrl/Cmd+F; F3; Shift+F3; Edit menu; FindBar,src/components/layout/FindBar.tsx; src/hooks/use-keyboard.ts; src-tauri/src/menu.rs,all,both,Most specialist workspaces set findBar false. | FindBar opened from store; regex toggle visible.,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z,"Find next/prev covered by use-app-actions and log-store tests. Browser Cmd+F walk was inconclusive (no FindBar landmark), not a confirmed product defect." +CHROME-008,chrome,Filter dialog,As an analyst I want AND field/op/value filters so I can hide noise.,"Ctrl+Shift+L or Filter... opens FilterDialog. Clauses: Log Text/Component/Thread/Date/Time/Severity with equals/not/contains/not/before/after. Apply calls apply_filter. Clear removes all clauses. Toolbar shows Filter (N) and click clears when active. Open surface is a dialog landmark (role=dialog, aria-modal, aria-label Filter).",Toolbar Filter; Ctrl+Shift+L; Edit > Filter; row context Include/Exclude,src/components/dialogs/FilterDialog.tsx; src/stores/filter-store.ts; src/components/layout/Toolbar.tsx,all,both,filter-store clause set/clear.,pass,retest,none,,code_review,2026-08-18T15:37:28Z,pass,2026-08-18T15:39:17Z,"Added role=dialog aria-modal aria-label=""Filter"" on FilterDialog surface." +CHROME-009,chrome,"Toggle sidebar, details, and info pane",As an analyst I want to hide chrome so the list has more room.,Ctrl+B toggles sidebar. Ctrl+H (Ctrl not Cmd on macOS) toggles extra columns. Info button toggles the bottom info pane. View menu mirrors these. Workspaces with sidebar/details/info false leave the toggles inert.,View menu; Toolbar Details/Info; Ctrl+B; Ctrl+H,src/hooks/use-keyboard.ts; src/components/layout/Toolbar.tsx; src/stores/ui-store.ts; src-tauri/src/menu.rs,all,both,Details/Info toggle updates status to Details off / Info off.,pass,test,none,,ui,2026-08-18T14:22:48Z,,, +CHROME-010,chrome,Always on top,As an analyst I want the window pinned above other apps while I follow a live log.,View > Always on Top persists and calls set_always_on_top. Re-applied on startup.,View > Always on Top,src-tauri/src/menu.rs; src/stores/ui-store.ts; src-tauri/src/commands/system_preferences.rs,all,both,Verified 2026-08-18T15:35:10Z: Preference persists; menu toggle invokes set_always_on_top. Tests: src/stores/ui-store.test.ts; src/hooks/use-app-menu.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-011,chrome,Pause and resume live tail,As an analyst I want to freeze incoming lines so I can read a burst.,Ctrl/Cmd+U or View > Pause/Resume or sidebar Pause/Resume calls pause_tail/resume_tail. Only the log workspace has tailing. Status bar shows Streaming or Paused.,Ctrl/Cmd+U; View menu; FileSidebar footer,src/hooks/use-keyboard.ts; src/stores/log-store.ts; src/components/layout/FileSidebar.tsx,all,both,log-store togglePause + menu toggle_pause.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-012,chrome,Refresh active source,As an analyst I want F5 to reload the current file or analysis.,"F5 or View > Refresh reloads the active source for the current workspace (log reparse, Intune re-analyze, Sysmon re-run, etc.).",F5; View > Refresh; sidebar Refresh,src/hooks/use-keyboard.ts; src/hooks/use-app-actions.ts; src/lib/log-source.ts,all,both,canRefresh + menu refresh routing.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-013,chrome,Adjust log list text size,As an analyst I want larger or smaller list text.,"Ctrl/Cmd + = / - / 0 increase, decrease, or reset logListFontSize when the workspace has fontSizing. Settings Appearance sliders do the same. Details pane has its own size.",Ctrl/Cmd+= - 0; View > Text Size; Settings > Appearance,src/hooks/use-keyboard.ts; src/stores/ui-store.ts; src/components/dialogs/settings/AppearanceTab.tsx,all,both,font inc/dec/reset store + menu.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-014,chrome,Switch workspace,As an analyst I want to change analysis mode without restarting.,"Toolbar workspace dropdown and Workspace menu list getAvailableWorkspaces(platform, enabledWorkspaces). Selecting one sets activeWorkspace and updates labels/capabilities.",Toolbar workspace; Workspace menu,src/components/layout/Toolbar.tsx; src/workspaces/registry.ts; src-tauri/src/menu.rs,all,both,Lite hides Full-only ids via enabledWorkspaces. | Workspace switcher in smoke + ESP e2e; registry filters platforms.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-015,chrome,Theme picker,As an analyst I want readable severity colors.,"Theme menu applies one of Classic CMTrace, Light, Dark, Dracula, Nord, Solarized Dark, High Contrast, Hot Dog Stand immediately and persists themeId.",Toolbar ThemePicker; Settings > Appearance,src/components/layout/ThemePicker.tsx; src/components/dialogs/settings/AppearanceTab.tsx,all,both,"Theme menu lists Light, High Contrast, Solarized Dark, Dracula.",pass,test,none,,ui,2026-08-18T14:22:48Z,,, +CHROME-016,chrome,Error code lookup,As an analyst I want hex/decimal/text search of the embedded catalog.,"Ctrl+L / Ctrl+E or Tools > Error Code Lookup opens ErrorLookupDialog. Code-like queries search immediately; text is debounced 300ms. Results show category, hex, description, Copy. History reruns. Prefills lookupErrorCode when opened from a span or Quick Stats.",Ctrl+L; Ctrl+E; Toolbar; Tools menu; error span; Quick Stats; row context,src/components/dialogs/ErrorLookupDialog.tsx; src/hooks/use-keyboard.ts,all,both,Error Code Lookup Fluent dialog opened with Close.,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z,Toolbar wires Error lookup to showErrorLookupDialog. Existing store tests cover open. Browser walk did not prove the Fluent Dialog failed. +CHROME-017,chrome,GUID registry dialog,As an Intune admin I want GUID-to-name lookup after analysis.,Tools > GUID Registry opens tabs All/Apps/Scripts/Remediations. Filter by name/GUID/publisher. Click copies GUID. Empty state tells the user to run Intune analysis or enable Graph.,Tools > GUID Registry,src/components/dialogs/GuidRegistryDialog.tsx,all,full,GUID Registry dialog: No GUID registry data available. Run an Intune analysis or enable Graph API in Settings.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:37:56Z, +CHROME-018,chrome,Evidence bundle summary,As an analyst I want to inspect a collected bundle inventory.,Tools > Evidence Bundle Summary opens EvidenceBundleDialog with Summary/Inventory/Notes/Manifest. Artifact buttons open in the active workspace or preview registry/EVTX.,Tools > Evidence Bundle Summary,src/components/dialogs/EvidenceBundleDialog.tsx,all,full,Verified 2026-08-18T15:35:10Z: Dialog landmark + Summary/Inventory/Notes/Manifest empty and error states. Tests: src/components/dialogs/EvidenceBundleDialog.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-019,chrome,Collect diagnostics,As a Windows analyst I want a scoped evidence zip.,"Tools > Collect Diagnostics (Windows + collector feature) opens CollectDiagnosticsDialog. Presets and family checkboxes. Collect starts collectDiagnostics and shows shell progress. CollectionCompleteDialog shows counts, gaps, Close, Open Bundle. Collect and complete overlays are dialog landmarks.",Tools > Collect Diagnostics,src/components/dialogs/CollectDiagnosticsDialog.tsx; src/components/dialogs/CollectionCompleteDialog.tsx,windows,full,"Verified 2026-08-18T15:35:10Z: Presets, category checkboxes, Collect starts mocked collectDiagnostics; complete dialog shows counts/gaps/Close/Open Bundle. Tests: src/components/dialogs/CollectDiagnosticsDialog.test.tsx; src/hooks/use-app-menu.test.tsx",pass,retest,none,,code_review,2026-08-18T15:37:28Z,pass,2026-08-18T15:39:17Z,Added role=dialog aria-modal and labels on CollectDiagnosticsDialog and CollectionCompleteDialog. +CHROME-020,chrome,Settings dialog,"As a user I want appearance, columns, behavior, updates, associations, and Graph in one place.","Settings opens tabs Appearance, Columns, Behavior, Updates; plus File Associations and Graph API on Windows. Esc/overlay/Close dismisses. Arrow/Home/End move tabs.","Ctrl/Cmd+,; Tools/App Settings",src/components/dialogs/SettingsDialog.tsx,all,both,,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:36:43Z,Settings already had role=dialog; added landmark assertion. Not a product bug. +CHROME-021,chrome,Settings appearance,"As a user I want theme, list size, details size, and font family.","Theme select, list/details sliders, font picker including Default (System), preview, Reset Defaults.",Settings > Appearance,src/components/dialogs/settings/AppearanceTab.tsx,all,both,"Verified 2026-08-18T15:35:10Z: Theme, sliders, font, preview, Reset Defaults. Tests: src/components/dialogs/settings/AppearanceTab.test.tsx; src/components/dialogs/SettingsDialog.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-022,chrome,Settings columns reset,As a user I want default column order and widths back.,Columns tab reports custom order/widths or defaults. Reset to Defaults clears persisted layout.,Settings > Columns,src/components/dialogs/settings/ColumnsTab.tsx,all,both,Verified 2026-08-18T15:35:10Z: Reset to Defaults clears custom column layout. Tests: src/components/dialogs/settings/ColumnsTab.test.tsx; src/components/dialogs/SettingsDialog.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-023,chrome,Settings behavior,As a user I want default info pane and tab-close confirm.,Checkboxes: Show info pane by default; Confirm before closing tabs.,Settings > Behavior,src/components/dialogs/settings/BehaviorTab.tsx,all,both,Verified 2026-08-18T15:35:10Z: Info pane default and confirm-close checkboxes write ui-store. Tests: src/components/dialogs/settings/BehaviorTab.test.tsx; src/components/dialogs/SettingsDialog.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-024,chrome,Settings updates,As a user I want startup update checks unless policy disables them.,Checkbox Check for updates on startup is disabled if updateChecksDisabledByPolicy. Channel badge shown. Clear skipped version if set.,Settings > Updates,src/components/dialogs/settings/UpdatesTab.tsx; src/lib/commands.ts getUpdatePolicy,all,both,UpdatesTab policy checkbox.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-025,chrome,Windows file association,As a Windows user I want .log files to open in CMTrace Open.,Settings > File Associations: Associate or Re-register. First-run FileAssociationPromptDialog: Associate / Don't Ask Again / Ask Later. Non-Windows tab is a static note. First-run prompt is a dialog landmark.,Settings > File Associations; first-run prompt,src/components/dialogs/settings/FileAssociationsTab.tsx; src/components/dialogs/FileAssociationPromptDialog.tsx,windows,both,Verified 2026-08-18T15:35:10Z: Non-Windows static note; Windows Associate button; first-run Associate / Don't Ask Again / Ask Later. Tests: src/components/dialogs/settings/FileAssociationsTab.test.tsx,pass,retest,none,,code_review,2026-08-18T15:37:28Z,pass,2026-08-18T15:39:17Z,Added role=dialog aria-modal aria-label on FileAssociationPromptDialog. +CHROME-026,chrome,Graph API settings,As a Windows admin I want optional WAM Graph for GUID names.,Enable Graph requires confirm. Then Sign in / Cancel; Request missing permissions; Pre-populate app cache; Sign out. Non-Windows: unavailable.,Settings > Graph API,src/components/dialogs/settings/GraphApiTab.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Enable/sign-in/permissions/sign-out/cache; non-Windows unavailable note. Tests: src/components/dialogs/settings/GraphApiTab.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-027,chrome,About dialog,"As a user I want version, channel, runtime, and license.","About shows name, version, channel, Tauri/React/TS/Rust, MIT, app ID, github.com/adamgell/cmtraceopen. OK/Esc/overlay close.",Help/App > About,src/components/dialogs/AboutDialog.tsx,all,both,,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:36:43Z,About panel has role=dialog aria-modal aria-label About CMTrace Open. AboutDialog.test.tsx asserts the landmark. +CHROME-028,chrome,Check for updates,As a user I want to download or skip an update.,"Check for Updates opens UpdateDialog. States: checking (Cancel), available (Skip / Later / Download & install or GitHub), downloading (percent, no Esc), up-to-date/error (OK). Update overlay is a dialog landmark (aria-label Check for Updates).",Help > Check for Updates,src/components/dialogs/UpdateDialog.tsx,all,both,Verified 2026-08-18T15:35:10Z: Update dialog states + Updates tab checkbox/channel/clear skipped. Tests: src/components/dialogs/UpdateDialog.test.tsx; src/components/dialogs/settings/UpdatesTab.test.tsx,pass,retest,none,,code_review,2026-08-18T15:37:28Z,pass,2026-08-18T15:39:17Z,"Added role=dialog aria-modal aria-label=""Check for Updates"" on UpdateDialog." +CHROME-029,chrome,Drag and drop files,As an analyst I want to drop files onto the window to open them.,Dropped paths go to the active workspace onOpenPath or the generic loader. Timeline unions dropped paths. Log workspace accepts multi-file drop.,Window drop,src/hooks/use-drag-drop.ts,all,both,"Verified 2026-08-18T15:35:10Z: Single/multi drop routing for log, non-log, and timeline. Tests: src/hooks/use-drag-drop.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +CHROME-030,chrome,OS file association launch,As a user I want double-clicking a .log to open it in the app.,get_initial_file_paths opens positional launch paths. get_initial_workspace can land on ESP. Elevation restore ticket is claimed separately and never treated as a file path.,OS file association; CLI args,src/hooks/use-file-association.ts; src-tauri/src/lib.rs parse_initial_launch_arguments,all,both,File-association launch + elevation ticket never treated as a path.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-031,chrome,Escape dismisses transient dialogs,As a user I want Esc to close find/filter/about without leaving the file.,Esc calls dismissTransientDialogs unless focus is in an input. Elevation prompt swallows Ctrl/Cmd and leaves Esc to the dialog.,Esc,src/hooks/use-keyboard.ts; src/hooks/use-app-actions.ts,all,both,closeTransientDialogs + elevation swallows Ctrl.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +CHROME-032,chrome,Status bar,"As an analyst I want stream, filter, parser, and Graph state without extra clicks.","Log view: stream, chrome, source, tab, parser, elapsed, position, lines, severity, format, filter. Other workspaces show their analysis summary. Graph dot if not disconnected. No buttons.",StatusBar,src/components/layout/StatusBar.tsx,all,both,Status bar shows Idle • Log view • Details on • Info on • Source No source selected.,pass,test,none,,ui,2026-08-18T14:22:48Z,,, +LOG-001,log,Auto-detect and parse log formats,"As an analyst I want CCM, CBS, DISM, Panther, simple, and plain text to open without picking a parser.",open_log_file / parse_files_batch detect format from sampled lines and show entries with severity colors. Encoding falls back UTF-8 then Windows-1252.,Open file/folder; drag-drop; known sources,src/lib/log-source.ts; crates/cmtraceopen-parser; src-tauri/src/parser,all,both,Verified 2026-08-18T15:35:10Z: detect_parser covers CCM/simple/plain/timestamped/CBS/DISM/Panther; decode_bytes UTF-8 then Windows-1252. Tests: crates/cmtraceopen-parser/src/parser/detect.rs,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-002,log,Virtualized log list,As an analyst I want smooth scrolling on 100K+ line files.,LogListView virtualizes rows. Click selects. Ctrl/Cmd+click toggles. Shift+click ranges. Arrows/Page/Home/End move selection among displayed (filtered) ids.,Log list,src/components/log-view/LogListView.tsx; src/hooks/use-keyboard.ts,all,both,Verified 2026-08-18T15:35:10Z: Virtualized rows render; click selects; Ctrl/Cmd+click toggles; Shift+click ranges. Tests: src/components/log-view/LogListView.selection.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-003,log,Jump to previous/next error,As an analyst I want to hop between Error rows.,Error toolbar shows live Error count. Previous/Next disabled at 0. Click selects the adjacent Error among currently displayed rows.,LogListView error toolbar,src/components/log-view/LogListView.tsx,all,both,Empty: 0 errors prev/next disabled. Seeded: 8 errors; Next error selected id 1.,pass,test,none,,ui,2026-08-18T14:22:48Z,,, +LOG-004,log,"Sort, reorder, resize, autofit columns",As an analyst I want to arrange columns to fit the case.,"Header sort cycles asc/desc (dateTime, lineNumber, severity order, localeCompare). Drag reorders and persists. Resize grip persists widths. Double-click autofits one column; severity header autofits all. Opening a source auto-expands message without a horizontal scrollbar.",Column headers,src/components/log-view/LogListView.tsx; src/stores/ui-store.ts,all,both,"Column headers Date/Time, Log Text, Component, Thread with sort + autofit controls. e2e no horizontal overflow.",pass,test,none,,ui,2026-08-18T14:22:48Z,,, +LOG-005,log,Copy selected rows,As an analyst I want selected lines on the clipboard including Windows clipboard history.,Focused list Ctrl/Cmd+C: one row is message\tcomponent\ttimestamp\tthread; many are messages newline-separated. Global Ctrl+C copies the selected entry unless the list is focused or a text selection exists. Clipboard-history mirror rewrites native copies through the plugin.,Ctrl/Cmd+C; context Copy Line/Message/Timestamp,src/components/log-view/LogListView.tsx; src/hooks/use-keyboard.ts; src/hooks/use-clipboard-history-mirror.ts,all,both,clipboard copy + history mirror tests.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +LOG-006,log,Select all visible rows,As an analyst I want to select every displayed row.,Ctrl/Cmd+A on the focused list selects all displayEntries after filter/sort.,Ctrl/Cmd+A,src/components/log-view/LogListView.tsx,all,both,Verified 2026-08-18T15:35:10Z: Ctrl/Cmd+A selects all displayed rows. Tests: src/components/log-view/LogListView.selection.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-007,log,Row context menu,"As an analyst I want copy, include/exclude, jump, markers, error lookup, and reveal source.",Native menu: Copy Line/Message/Timestamp; Include/Exclude selection or full message (adds a live filter clause); Jump to Line; marker add/change/remove; Error Lookup if a code span; Open Source File reveals entry.sourceFile.,Right-click row,src/hooks/use-context-menu.ts,all,both,Verified 2026-08-18T15:35:10Z: Copy Line/Message/Timestamp; Include/Exclude; Jump to Line; Mark as Bug/Investigate/Confirmed; Error Lookup; Open Source File. Tests: src/hooks/use-context-menu.test.ts,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-008,log,Click error code in a message,As an analyst I want to inspect a highlighted HRESULT/Win32 code.,"Dotted underline spans stopPropagation, open the info pane if hidden, and set focusedErrorCode. Enter/Space on the span does the same.",Message error-code span,src/components/log-view/LogRow.tsx; src/components/log-view/InfoPane.tsx,all,both,Verified 2026-08-18T15:35:10Z: Dotted HRESULT span click/Enter stops row selection and reports the span. Tests: src/components/log-view/LogRow.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-009,log,Per-file line markers,As an analyst I want colored bookmarks that persist per file.,Gutter click toggles the active category (default Bug). Ctrl/Cmd+M on the selected row. Right-click gutter: Bug / Investigate / Confirmed / Remove. Debounced save_markers; empty file delete_markers. Disabled in merged mode.,Gutter; Ctrl+M; context menu,src/components/log-view/LogRow.tsx; src/stores/marker-store.ts,all,both,Verified 2026-08-18T15:35:10Z: Gutter click toggles marker; context menu Bug/Investigate/Confirmed/Remove. Tests: src/components/log-view/LogRow.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-010,log,Highlight text without Find,As an analyst I want a persistent highlight that is not a find session.,Toolbar Highlight input sets highlightText. Matching substrings are marked. Separate from FindBar case/regex.,Toolbar Highlight,src/components/layout/Toolbar.tsx; src/components/log-view/LogRow.tsx,all,both,Highlight input sets highlightText=HRESULT.,pass,test,none,,ui,2026-08-18T14:22:48Z,,, +LOG-011,log,Follow live tail unless scrolled up,As an analyst I want new lines to stick to the bottom unless I scroll up or pause.,"If within 50px of the bottom and not paused, new rows scroll to end.",Live stream,src/components/log-view/LogListView.tsx,all,both,"Verified 2026-08-18T15:35:10Z: New rows call scrollToIndex(last, end) when not paused and at bottom. Tests: src/components/log-view/LogListView.selection.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-012,log,Section and iteration banners,As an analyst I want section headers to stand out.,Section/Iteration rows render SectionDividerRow; click selects. Regular rows in a section get a left band.,Virtualizer,src/components/log-view/SectionDividerRow.tsx,all,both,Verified 2026-08-18T15:35:10Z: Section banner click selects; Iteration caption shown. Tests: src/components/log-view/SectionDividerRow.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-013,log,WhatIf row styling,As an analyst I want simulated lines marked.,"whatif rows: 60% opacity, italic message, WhatIf chip. filter-store.whatIfFilter has no chrome and is not applied in LogListView.",Severity cell,src/components/log-view/LogRow.tsx; src/stores/filter-store.ts,all,both,Store-only whatIfFilter; no UI. | Seeded WhatIf row showed a WhatIf chip in the list.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:38:59Z, +LOG-014,log,Jump to line from another workspace,As an analyst I want a deployment handoff to land on a line.,pendingScrollTarget matching openFilePath selects the first entry with lineNumber>=target then clears.,log-store.setPendingScrollTarget,src/components/log-view/LogListView.tsx; src/stores/log-store.ts,all,both,Verified 2026-08-18T15:35:10Z: pendingScrollTarget matching openFilePath selects first lineNumber>=target and clears. Tests: src/components/log-view/LogListView.selection.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-015,log,Merge open tabs,As an analyst I want a unified timeline of two or more log tabs.,"Merge tabs... (log workspace, >=2 tabs) opens MergeTabsDialog. Checkboxes of non-registry tabs. Merge disabled under 2. Result is a merged tab with MergeLegendBar.",Toolbar Merge tabs; MergeTabsDialog,src/components/dialogs/MergeTabsDialog.tsx; src/components/log-view/MergeLegendBar.tsx,all,both,Merge Tabs dialog: Open at least two log files; Merge (0 files) disabled.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:37:56Z, +LOG-016,log,Merged-tab legend and correlation,As an analyst I want to hide contributing files and set a correlation window.,Per-file chips toggle visibility; All/None; Correlate 100ms/500ms/1s/5s/10s; Auto; count N merged. Info pane lists up to 20 correlated rows; click selects.,MergeLegendBar; Info pane,src/components/log-view/MergeLegendBar.tsx; src/components/log-view/InfoPane.tsx,all,both,"Verified 2026-08-18T15:35:10Z: File chips, All/None, Correlate windows, Auto, N merged. Tests: src/components/log-view/MergeLegendBar.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-017,log,Diff two open logs,As an analyst I want unique vs common lines highlighted.,Diff tabs... opens DiffConfigDialog for two log tabs. Header shows common/only-A/only-B. Side-by-side synced scroll or Unified. Click selects. Close exits.,Toolbar Diff tabs; DiffConfigDialog,src/components/log-view/DiffView.tsx; src/components/log-view/DiffHeader.tsx; src/components/dialogs/DiffConfigDialog.tsx,all,both,Compare Log Files dialog: Source A/B selects + Compare.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:37:56Z, +LOG-018,log,DNS/DHCP handoff banner,As an analyst I want a specialist view when a DNS/DHCP log is detected.,If parser is dnsDebug/dnsAudit/dhcp and not dismissed: Open in workspace adds the source to dns-dhcp and switches workspace; Dismiss hides for the session.,DnsWorkspaceBanner,src/components/log-view/DnsWorkspaceBanner.tsx,all,both,Verified 2026-08-18T15:35:10Z: dnsDebug banner; Open in workspace adds source and switches; Dismiss hides. Tests: src/components/log-view/DnsWorkspaceBanner.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-019,log,Info pane for selected entry,"As an analyst I want the full line, parser, codes, GUIDs, and app-policy decode.","Shows line|severity|component|time, file, parser, Result/GLE/Phase/Op, GUID chips, AppWorkloadScriptDetail, correlated list, message. Error banner: Open lookup / Dismiss. Empty states for no entries / no selection.",Toolbar Info; error span; defaultShowInfoPane,src/components/log-view/InfoPane.tsx; src/components/log-view/AppWorkloadScriptDetail.tsx,all,both,Empty info pane: No log entries loaded. | Selecting the 0x80070005 row surfaced that code in the info pane.,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:38:59Z, +LOG-020,log,Resize info pane,As an analyst I want more or less detail height.,4px row-resize separator; height clamped 80px to 70% viewport during drag. infoPaneHeight is session-only (not persisted).,Info pane separator,src/components/layout/AppShell.tsx,all,both,Persist partialize does not include infoPaneHeight. | Verified 2026-08-18T15:35:10Z: 4px row-resize clamp 80px–70% viewport is in AppShell drag handler. Height is session-only (not in persist partialize). Tests: src/components/layout/AppShell.tsx; src/stores/ui-store.ts,pass,test,none,,code_review,2026-08-18T15:35:10Z,,, +LOG-021,log,File tabs,As an analyst I want multiple files open.,Click/Enter/Space switches. Arrows/Home/End among visible tabs. Close X closes the tab (confirm if confirmTabClose). Overflow chevron lists the rest. Merged tab label Merged (N files).,TabStrip,src/components/layout/TabStrip.tsx,all,both,ui-store tab open/close/switch.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +LOG-022,log,Folder sidebar file list,As an analyst I want sibling logs listed with size and mtime.,Reload source; Retry after fail; files click loadSelectedLogFile and clearFilter; Active/Loading badges. >=2 files: Merge into Timeline (cached snapshots). Footer Pause/Resume and Refresh when footerBar.,FileSidebar,src/components/layout/FileSidebar.tsx,all,both,"Folder source sidebar: Reload source, This folder is empty, Select a file to populate the main log list.",pass,retest,none,,ui,,retest_pass,2026-08-18T14:38:59Z, +LOG-023,log,Quick stats severity cards,As an analyst I want a rollup I can filter or look up.,Hidden if no entries. Cards Errors/Warnings/Info/Success click-filter those IDs. Table sortable Code/Description/Category/Count; row click opens Error Lookup. Time range text.,Above log list,src/components/panels/QuickStatsPanel.tsx,all,both,,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:36:43Z,Collapsed-by-default is intentional so the log list stays visible. Header still shows Quick Stats + total count. Expanding-by-default broke layout in e2e; reverted. +LOG-024,log,Registry snapshot tab,As an analyst I want keys and values when a .reg tab is active.,"Parses via parse_registry_file. Header key/value counts. Splitter >=180px. Tree: click select, chevron/dblclick expand, arrows. Values: Name/Type/Data.",Open .reg; AppShell when fileKind=registry,src/components/registry-view/RegistryViewer.tsx; KeyTree.tsx; ValueTable.tsx,all,both,Verified 2026-08-18T15:35:10Z: Header key/value counts; tree click; Name/Type/Data values. Tests: src/components/registry-view/RegistryViewer.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-025,log,Folder parse progress overlay,As an analyst I want progress while a folder loads.,Overlay spinner + ProgressBar + N of M + current file while folderLoadProgress is set.,Folder open,src/components/layout/AppShell.tsx,all,both,Verified 2026-08-18T15:35:10Z: folderLoadProgress drives Parsing N of M — current file status. AppShell overlay uses the same store fields. Tests: src/components/layout/StatusBar.folder-progress.test.tsx; src/stores/log-store.test.ts,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +LOG-026,log,dsregcmd paste and live capture from toolbar,As a Windows admin I want Paste and Capture when dsregcmd is active.,"On dsregcmd workspace the Open menu adds Paste clipboard and Capture live output, calling pasteDsregcmdSource / captureDsregcmdSource.",Toolbar Open menu,src/components/layout/Toolbar.tsx; src/hooks/use-app-actions.ts,windows,full,Verified 2026-08-18T15:35:10Z: Open menu adds Paste clipboard and Capture live output; workspace/sidebar Capture/Paste buttons. Tests: src/components/layout/Toolbar.dsregcmd.test.tsx; src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-001,intune,Open IME log or evidence folder,As an Intune admin I open an IME log or evidence folder so I can see parsed app/script/download activity.,Open IME Log File / Open IME Or Evidence Folder (disabled while analyzing). createIntuneOnOpenSource analyzes the path. includeLiveEventLogs only for known source windows-intune-ime-logs. Empty folder -> empty phase. Failures -> error phase.,IntuneDashboardHeader; File menu; known source,src/workspaces/intune/index.ts; IntuneDashboardHeader.tsx; intune-store.ts,all,full,intune-store phase machine. | Classic Intune empty: Choose an Intune log file or folder; Included files: 0.,pass,retest,none,,existing_test,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z, +INTUNE-002,intune,Classic Timeline / Downloads / Summary tabs,"As an admin I switch among timeline, downloads, and summary.",Tabs disable when analyzing or when that surface has no data. If the active tab becomes unavailable the dashboard falls back timeline then downloads then summary.,IntuneDashboardNavBar,src/workspaces/intune/IntuneDashboard.tsx; IntuneDashboardNavBar.tsx,all,full,Verified 2026-08-18T15:35:10Z: Timeline/Downloads/Summary tabs switch; Downloads disabled when empty. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-003,intune,Time window filter,As an admin I limit events and downloads to a recent window.,All Activity / Last Hour / Last 6 Hours / Last Day / Last 7 Days. Anchor is latest event or download timestamp. Summary diagnostics stay full-set and warn they are unwindowed.,Nav bar Window select,src/workspaces/intune/IntuneDashboardNavBar.tsx,all,full,Verified 2026-08-18T15:35:10Z: Time window presets hide out-of-window events; summary stays unwindowed. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-004,intune,Timeline type/status/sort/activity,"As an admin I filter by type and status, switch list vs activity, and sort.",Type All/Win32/WinGet/Script/Remediation/ESP/Sync/Policy/Download/Other; status All/Success/Failed/In Progress/Pending/Timeout/Unknown; Reset; list|activity; sort time/name/type/status/duration.,IntuneDashboardNavBar,src/workspaces/intune/IntuneDashboardNavBar.tsx; EventTimeline.tsx,all,full,"Verified 2026-08-18T15:35:10Z: Type/status filters, sort, List/Activity, Reset. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-005,intune,Scope timeline to one included file,As an admin I click an included file to see only that log.,Sidebar file click toggles Scoped. Nav shows Timeline scoped to file + clear. Clicking the active file again clears scope.,IntuneSidebar; nav clear,src/workspaces/intune/IntuneSidebar.tsx; EventTimeline.tsx,all,full,Verified 2026-08-18T15:35:10Z: Sidebar file click scopes timeline; Clear Scope restores. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-006,intune,Inspect and copy an IME event,"As an admin I expand an event to see detail, error, source line, and script body.",Click/Enter/Space expands. Copy error+context or Copy details. ScriptCodeViewer Copy when scriptBody present.,EventTimelineRow,src/workspaces/intune/EventTimelineRow.tsx; ScriptCodeViewer.tsx,all,full,Verified 2026-08-18T15:35:10Z: Expand failed event shows failure context and copy. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-007,intune,Download statistics table,"As an admin I see DO percentage, speed, size, and success/fail.","Sortable Status/Content/Size/Speed/DO %/Dur./Timestamp. Header aggregates file count, success, failure, transferred bytes. Empty: No content download events were found.",Downloads tab,src/workspaces/intune/DownloadStats.tsx,all,full,Verified 2026-08-18T15:35:10Z: Downloads table columns and empty copy. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-008,intune,"Summary findings, coverage, confidence","As an admin I want cited findings and coverage gaps, not a raw scrollback.",Conclusions jump to coverage/confidence/repeatedFailures/guidance. Coverage shows file/family/rotated/dominant/timestamp bounds. Confidence level/score/reasons. Remediation Assistant. Activity metrics.,Summary tab,src/workspaces/intune/SummaryView.tsx,all,full,Verified 2026-08-18T15:35:10Z: Summary coverage/confidence/repeated failures/remediation. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-009,intune,Graph GUID name enrichment,As an admin I want friendly names when Graph is already enabled.,analyzeIntuneLogs is called with graphApiEnabled. guidRegistry GraphApi names appear in activity view. No Graph panel or device picker in Intune.,Analysis path,src/workspaces/intune/index.ts; EventActivityView.tsx,all,full,Verified 2026-08-18T15:35:10Z: Graph registry names activity titles; analyze path graphApiEnabled. Tests: src/workspaces/intune/IntuneDashboard.stories.test.tsx; src/workspaces/intune/createIntuneOnOpenSource.test.ts,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-010,new-intune,New Intune empty-state live or snapshot start,As an admin I choose live IME+event logs or a captured file/folder.,"Empty card: Analyze live logs + event logs (known source windows-intune-ime-logs, disabled if !canOpenKnownSources); Open IME log file; Open IME or evidence folder.",NewIntuneWorkspace empty state,src/workspaces/intune/NewIntuneWorkspace.tsx,all,full,New Intune empty: Select an IME log source to begin analysis.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:37:56Z, +INTUNE-011,new-intune,New Intune surfaces and reset,"As an admin I move from triage to timeline, downloads, or Windows event logs.",Tabs Overview; Event evidence; Download evidence; Event log evidence (error-count badge). Reset investigation clears type/status/file scope/selected event. Refresh analysis re-runs.,NewIntuneWorkspace hero and surface nav,src/workspaces/intune/NewIntuneWorkspace.tsx,all,full,Verified 2026-08-18T15:35:10Z: Overview/Event/Download/Event log evidence + Reset investigation. Tests: src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-012,new-intune,New Intune overview triage,As an admin I start from the worst signals and jump to proof.,"Metrics: Active issues, Repeated failures, Evidence confidence, Dominant source, Event log signals, Content downloads. Priority issues Show timeline/downloads/scope/event logs. Failure patterns. Coverage families. Correlated event-log jump.",Overview surface,src/workspaces/intune/OverviewSurface.tsx,all,full,"Verified 2026-08-18T15:35:10Z: Priority issues, failure patterns, source coverage, correlated event-log evidence. Tests: src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +INTUNE-013,new-intune,New Intune event-log evidence,As an admin I filter live or captured event-log evidence and jump back to IME events.,Channel + severity filters; channel chips; virtualized expand. Live empty shows per-channel status. Correlation links navigate to timeline event ids.,Event log evidence tab,src/workspaces/intune/EventLogSurface.tsx,all,full,"Verified 2026-08-18T15:35:10Z: Channel/severity filters, linked row, View in Timeline. Tests: src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +ESP-001,esp,Import captured ESP evidence,"As an admin I import a folder, manifest.json, CAB, ZIP, or session JSON without starting live collection.","Import evidence folder / Import captured evidence. JSON that parses as a session is replayed. Live start/live/stopping blocks import. Unsupported path shows ESP Diagnostics accepts CMTrace evidence folders, manifest.json, CAB, or ZIP sources.",ESP header; workspace onOpenSource,src/workspaces/esp-diagnostics/index.ts; EspDiagnosticsWorkspace.tsx,all,full,e2e imports captured ZIP; rejects unsupported; live blocks open. | ESP cockpit shows Import evidence folder / Import captured evidence / Open session.,pass,retest,none,,existing_test,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z, +ESP-002,esp,Open or export a redacted ESP session,As an admin I hand off or replay a session capture.,Open session loads parseEspSessionCapture. Export session (disabled without snapshot) writes redacted JSON via export_esp_session.,Header Open session / Export session,src/workspaces/esp-diagnostics/EspDiagnosticsWorkspace.tsx,all,full,session capture parse/serialize.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-003,esp,Start and stop live ESP collection,As a Windows admin I collect live ESP evidence from this device.,Start live diagnostics disabled off Windows or while analyzing/starting/stopping. Polls getEspDiagnosticsSession while starting. Stop calls stopEspDiagnosticsSession. Empty copy: Live acquisition requires Windows.,Header Start/Stop,src/workspaces/esp-diagnostics/EspDiagnosticsWorkspace.tsx,windows live; import all,full,e2e start/stop live session via typed IPC. | ESP cockpit shows Start live diagnostics on empty state.,pass,retest,none,,existing_test,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z, +ESP-004,esp,Live evidence dock,"As an admin I open, resize, or hide live evidence without leaving the cockpit.","Toolbar Open/Hide live logs with state dot, count, unread. Dock collapsed/docked/full; resize 180-720px or 70%; Escape collapses; collection continues when hidden.",EspToolbarAction; LiveEvidenceDock,src/workspaces/esp-diagnostics/EspToolbarAction.tsx; LiveEvidenceDock.tsx,all,full,Live dock open/hide/resize/Esc/unread (component + e2e).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-005,esp,ESP elevation banner,As an admin I know when protected sources are missing because the process is not elevated.,"Windows probe getEspElevationState. Header Administrator Elevated/Standard/Unknown. Banner lists restrictedSources and Restart as administrator (coverageRecommended, workspace esp-diagnostics) or manual relaunch if !restartSupported.",ElevationBanner; header metric,src/workspaces/esp-diagnostics/ElevationBanner.tsx,windows,full,Elevation banner + restart (component + e2e).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-006,esp,ESP findings and current task,As an admin I start from blockers and open cited evidence.,ActionCenter: EspNowStatus current task; findings sorted blocker>error>warning>info then confidence; recommended read-only checks; evidence and coverage-gap links.,Action center,src/workspaces/esp-diagnostics/ActionCenter.tsx; EspNowStatus.tsx,all,full,current task + finding drill e2e.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-007,esp,Force ESP past a failed app,As an elevated Windows admin on a live session I flip a failed app's ESP tracking state.,"Shown only windows && !replay && failedEspApps.length>0. Disabled unless elevated. Confirm writes InstallationState=Installed (3), keeps backup; Restore calls esp_restore_app_state. Does not install the app. Hidden on replay.",EspActions,src/workspaces/esp-diagnostics/EspActions.tsx,windows,full,Force-past-ESP confirm/restore (EspActions tests).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-008,esp,MSIEXEC correlation,As an admin I see installer PIDs and correlation confidence.,"MsiexecStatus rows: process, workload, exact/strong/temporal/uncorrelated, product code, evidence links. Labels zero, one, or many processes.",Beside Action Center,src/workspaces/esp-diagnostics/MsiexecStatus.tsx,all,full,MSIEXEC exact/ambiguous/zero rows (e2e).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-009,esp,Enrollment session selector,As an admin I filter workloads to one enrollment session.,Default latest sessions. Dropdown Latest · scope · start · id. Show all sessions checkbox. Search name/id/status/code. Status chips All/Failed/Running/Installed/Queued. Pages of 80.,EspWorkloadTable,src/workspaces/esp-diagnostics/EspWorkloadTable.tsx,all,full,workload status chips + search.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-010,esp,Graph enrichment panel,As an admin I refresh read-only Graph data without replacing local evidence.,Off if !graphApiEnabled. Does not open sign-in. Refresh disabled unless connected. Cancel while loading. Ambiguous deviceMatch requires Use device. Sections fail independently with Available/Not found/Permission denied/Failed/Skipped/Cancelled. Replay keeps captured overlay and does not auto-fetch.,Graph section,src/workspaces/esp-diagnostics/GraphEnrichmentPanel.tsx,all (WAM typically Windows),full,Graph enrichment independent section states (e2e).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-011,esp,ESP phase progress,As an admin I see which ESP stage is current or failed.,Classic: Device preparation / Device setup / Account setup. Device Preparation V2: Agent bootstrap / Policy + scripts / Applications + certificates / Completion. States Complete/Current/Pending/Failed/Unknown.,Phases panel,src/workspaces/esp-diagnostics/EspPhaseProgress.tsx,all,full,Classic and Device Preparation phases (e2e).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-012,esp,ESP live activity timeline,As an admin I scan independent live activity.,"Newest-first, 80-row Newer/Older window, Graph name rewrite, evidence links. Empty: No timeline occurrences observed yet.",Timeline panel,src/workspaces/esp-diagnostics/LiveActivity.tsx,all,full,live activity newest-first + Graph names.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-013,esp,ESP evidence coverage and masking,"As an admin I see missing/denied/parse-failed sources as coverage, not success.",Collapsible families with source-state badges. Reveal sensitive values / Mask; restricted values never revealed. Findings navigate into the matching item.,Evidence section,src/workspaces/esp-diagnostics/EvidenceSections.tsx,all,full,Coverage families + mask/reveal (e2e + view-model).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +ESP-014,esp,ESP section nav and header metrics,"As an admin I jump between cockpit sections and see scenario, phase, elapsed, coverage.","Header: Scenario, ESP phase, Elapsed, Local coverage, Local state, Graph, Administrator. EspSectionNav anchors when a snapshot exists.",EspWorkspaceHeader; EspSectionNav,src/workspaces/esp-diagnostics/EspWorkspaceHeader.tsx; EspSectionNav.tsx,all,full,Header metrics + section nav (e2e).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +DSREG-001,dsregcmd,"Capture, paste, or open dsregcmd evidence","As a Windows admin I load dsregcmd /status from live capture, clipboard, text file, or evidence folder.",Capture / Paste / Open text file / Open evidence folder. Known presets throw. Empty input fails. Analyzing and error empty states.,DsregcmdWorkspace; DsregcmdSidebar; Toolbar,src/workspaces/dsregcmd/DsregcmdWorkspace.tsx; src/lib/dsregcmd-source.ts,windows,full,dsregcmd-store ingest phases.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +DSREG-002,dsregcmd,Analysis vs Event Logs tabs,As an admin I toggle parsed facts versus collected event-log entries.,Tabs Analysis and Event Logs (count = eventLogAnalysis.totalEntryCount). Event Logs panel only if analysis exists.,Tab strip,src/workspaces/dsregcmd/DsregcmdWorkspace.tsx,windows,full,event-logs tab setter.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +DSREG-003,dsregcmd,Health cards and findings,"As an admin I get join, PRT, MDM, NGC, certificate health and issue cards.","Cards: Join Type, Current Stage, Capture Confidence, PRT State, MDM Signals, NGC, Certificate days. Issues Overview with evidence/nextChecks/suggestedFixes. Sidebar Top Findings first 8.",Analysis tab; sidebar,src/workspaces/dsregcmd/DsregcmdWorkspace.tsx; DiagnosticInsightsCard.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Health cards and findings after seeded analysis. Tests: src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +DSREG-004,dsregcmd,Fact groups including WHfB policy,As an admin I inspect extracted facts without reading raw line order.,"Show/Hide not reported fields. Groups include Policy Evidence (WHfB registry), connectivity, SCP, enrollment, OS, proxy. Timeline and flow boxes.",Facts by Group / Timeline / Flows,src/workspaces/dsregcmd/fact-group-builders.ts; FactGroupRenderer.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Facts by group including WHfB policy evidence. Tests: src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +DSREG-005,dsregcmd,dsregcmd event-log surface,As an admin I filter collected channel evidence for the capture.,"Channel/severity selects, summary chips, virtualized expand. Empty explains no entries plus liveQuery attempted/failed.",Event Logs tab,src/workspaces/dsregcmd/DsregcmdEventLogSurface.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Event Logs tab channel/severity and PRT row. Tests: src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +DSREG-006,dsregcmd,Export dsregcmd analysis,"As an admin I hand off JSON, summary, or raw status text.","Copy JSON, Copy status text, Copy summary, Save JSON, Save summary, Show/Hide raw input. Status toast 5s.",Export section,src/workspaces/dsregcmd/DsregcmdWorkspace.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Export Copy/Save JSON and summary actions. Tests: src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +SCCM-001,sccm,Discover ConfigMgr roles,As a Windows admin I detect installed client/site roles before capturing files.,"Discover / Refresh discovery. Ready strip: Collector Supported/Unavailable, ConfigMgr version, Observed roles with Registry/Service/CIM basis. Issue rail for unsupported, access denied, discovery failed. No host paths or identifiers shown.",SccmWorkspace header,src/workspaces/sccm/SccmWorkspace.tsx,windows,full,"Discover roles, no host paths (RTL).",pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +SCCM-002,sccm,Capture diagnostic bundle,As an admin I retain allow-listed sources for observed roles.,"Capture diagnostic bundle enabled only if discovery.supported && roles.length>0. Receipt: artifact count, retained bytes, captured time, Reveal bundle.",Header + receipt,src/workspaces/sccm/SccmWorkspace.tsx,windows,full,Capture receipt + reveal (RTL).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +SCCM-003,sccm,Authorize bounded advanced capture,"As an admin I optionally capture OSD/PXE, cert/PKI, reporting, cloud, or BGB from an operator-chosen root.",Choose candidate root (path not stored) -> confirm maxBytes/maxFiles -> authorizeSccmAdvancedCapture -> confirm Capture this bounded source now. Unmount/cancel clears capability. Blocked cards disabled.,Advanced sources panel,src/workspaces/sccm/SccmWorkspace.tsx,windows,full,Advanced authorize/capture/cancel (RTL).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +SCCM-004,sccm,SCCM coverage ledger,"As an admin I treat missing/denied/capped sources as coverage, not health.","Columns Source, Role, Rotation, State, Retained. States: Captured, Absent, Access denied, Capped, Skipped, Unsupported, Parse failed. Empty: No allow-listed SCCM sources were observed.",Source coverage panel,src/workspaces/sccm/SccmWorkspace.tsx,windows,full,Coverage ledger states (RTL).,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +EVTX-001,event-log,Open EVTX files from workspace picker,As an admin I open one or more .evtx files to inspect Windows events.,SourcePicker multi-select .evtx. parseFiles -> evtx_parse_files. sourceMode=files. Coverage gaps listed separately from hard loadError. File > Open / drag-drop while Event Log is active call eventLogWorkspace.onOpenSource -> openEventLogSource -> parseFiles. Folder listing keeps only .evtx files.,SourcePicker Open .evtx files,src/workspaces/event-log/SourcePicker.tsx; evtx-store.ts; src/workspaces/event-log/open-event-log-source.ts; src/workspaces/event-log/index.ts,all,full,Event Log empty picker: Open .evtx files... File Open and drag-drop route through onOpenSource.,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z,File > Open / drag-drop now call eventLogWorkspace.onOpenSource -> parseFiles. Folder listing keeps only .evtx. Elevation restore uses the same handler. Tests: open-event-log-source.test.ts + use-file-association.test.tsx. vitest 884/884. e2e 21/21. +EVTX-002,event-log,Browse live channels on this computer,As a Windows admin I query this machine's Event Log service.,"This computer (Windows UI only) enumerates then auto-queries Application, System, Security, Setup. sourceMode=live. Time window Last 1h/24h/7d/30d/all as XPath last-N. Load N / Refresh. Failed channels become coverage gaps.",SourcePicker This computer; ChannelPicker,src/workspaces/event-log/SourcePicker.tsx; ChannelPicker.tsx,windows live,full,live query + coverage gaps store.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +EVTX-003,event-log,Channel tree,As a user I pick which channels appear.,"Windows Logs + Applications and Services Logs. Filter channels, Select all / Deselect all. Search flattens the tree.",ChannelPicker,src/workspaces/event-log/ChannelPicker.tsx,all,full,Verified 2026-08-18T15:35:10Z: Channel tree Windows Logs / Applications and Services / select all. Tests: src/workspaces/event-log/EventLogWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +EVTX-004,event-log,"Filter, group, sort, columns, timezone",As a user I narrow the event list.,Toggle Crit/Err/Warn/Info/Verb; Event IDs; Search; Group by Level/Provider/Channel/Event ID/Day; Sort Time/Event ID/Level/Provider/Channel; Columns chooser + Reset + Reorder; TZ local↔UTC. Client-side except live time window.,EvtxFilterBar,src/workspaces/event-log/EvtxFilterBar.tsx,all,full,filter/group/columns/time helpers.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +EVTX-005,event-log,Saved filters,As a user I reuse a named filter.,Saved dropdown: Save current (inline name; no prompt) then apply. Persisted cmtraceopen-evtx-saved-filters. Applying a different time window refetches live channels.,EvtxFilterBar Saved,src/workspaces/event-log/evtx-saved-filters.ts,all,full,parseFilterExport/importFilters have no UI. | saved-filter helpers.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +EVTX-006,event-log,Export visible events,As a user I export exactly what I see.,Export CSV/TSV/JSON/Event XML of selectVisibleRecords. Save dialog events.. Reports count + KB or failure.,EvtxFilterBar Export,src/workspaces/event-log/EvtxFilterBar.tsx,all,full,Verified 2026-08-18T15:35:10Z: Export dropdown CSV/TSV/JSON/Event XML. Tests: src/workspaces/event-log/EventLogWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +EVTX-007,event-log,Event detail and raw XML,As a user I inspect a selected record.,"Resizable bottom pane: Event ID/time/level, message, Event Data, provider/channel/computer/record, optional mapped fields, Show/Hide Raw XML.",Click row in EvtxTimeline,src/workspaces/event-log/EvtxDetailPane.tsx,all,full,"Verified 2026-08-18T15:35:10Z: Event detail, Event Data, Show/Hide Raw XML. Tests: src/workspaces/event-log/EventLogWorkspace.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +EVTX-008,event-log,Coverage gaps banner,As a user I know what was not read.,Banner lists mergeCoverageGaps so absence is not mistaken for no events.,Auto when coverageGaps nonempty,src/workspaces/event-log/EvtxCoverageBanner.tsx,all,full,mergeCoverageGaps.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +SYSMON-001,sysmon,Analyze Sysmon EVTX or live log,As a Windows defender I analyze Sysmon events from a file or this PC.,Open .evtx or This computer -> analyzeSysmonLogs. Known windows-sysmon-live-events includes live EVTX. Progress spinner; errors shown. After success activeTab=dashboard. Refresh re-runs.,SysmonWorkspace empty state; File open; known source,src/workspaces/sysmon/SysmonWorkspace.tsx; index.ts,windows,full,Verified 2026-08-18T15:35:10Z: Empty open actions + setResults dashboard. Tests: src/workspaces/sysmon/SysmonWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +SYSMON-002,sysmon,"Sysmon dashboard, events, summary, config",As a user I switch Sysmon views.,"Tabs Dashboard (metrics, alerts, top talkers), Events (type/severity/search + expand), Summary (Event ID counts), Configuration (schema/version/hashes/Event ID 16 XML).",SysmonWorkspace tabs,src/workspaces/sysmon/SysmonWorkspace.tsx; SysmonEventTable.tsx; SysmonDashboardView.tsx; SysmonSummaryView.tsx; SysmonConfigView.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Dashboard/Events/Summary/Configuration tabs. Tests: src/workspaces/sysmon/SysmonWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +SB-001,secureboot,Analyze live device or log,As an admin I determine UEFI CA 2023 / Secure Boot stage.,"File -> analyzeSecureBoot(path). Non-file -> live scan. Known sources throw. Windows sidebar: Run detection, Run remediation (confirm), Rescan. Stages 0-5 from Disabled to Compliant.",File open; SecureBootSidebar; StatusBanner Rescan,src/workspaces/secureboot/index.ts; SecureBootWorkspace.tsx; SecureBootSidebar.tsx,all (script buttons Windows),full,Secure Boot empty: Use the toolbar actions to scan this device or open a Secure Boot log file.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:37:56Z, +SB-002,secureboot,"Diagnostics, timeline, raw dump","As a user I read findings, session timeline, and copy the registry dump.","Diagnostics cards: severity, ruleId, title, detail, recommendation. Timeline: Timestamp/Source/Message. Raw Data: rawRegistryDump with Copy.",Diagnostics / Timeline / Raw Data tabs,src/workspaces/secureboot/DiagnosticsTab.tsx; TimelineTab.tsx; RawDataTab.tsx,all,full,"Verified 2026-08-18T15:35:10Z: Diagnostics card, timeline columns, raw dump + Copy. Tests: src/workspaces/secureboot/SecureBootWorkspace.test.tsx",pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +MACDIAG-001,macos-diag,Scan Mac environment and FDA gate,As a Mac admin I grant Full Disk Access then scan.,"On open: macosScanEnvironment. If fullDiskAccess=notGranted: guide with Re-check FDA and Open System Settings. Ready banner: version/build, FDA pill, tools, Refresh all.",Workspace auto-scan; FDA guide,src/workspaces/macos-diag/MacosDiagWorkspace.tsx; MacosDiagFdaGuide.tsx,macos,full,Verified 2026-08-18T15:35:10Z: FDA notGranted gate + ready banner with OS/FDA/tools. Tests: src/workspaces/macos-diag/MacosDiagWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +MACDIAG-002,macos-diag,"Intune logs, profiles, Defender, packages, unified log","As a Mac admin I inspect Intune logs, MDM profiles, Defender, pkgutil, and unified log.","Intune Logs: table + Open in Log Explorer. Profiles & MDM: enrollment + ProfileDrilldown + Copy all. Defender: health/RTP/definitions + open logs. Packages: list + details/files. Unified Log: presets, time, cap, Hide NSURLSession, Run Query, virtual table.",MacosDiag tabs,src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx; MacosDiagProfilesTab.tsx; MacosDiagDefenderTab.tsx; MacosDiagPackagesTab.tsx; MacosDiagUnifiedLogTab.tsx,macos,full,Verified 2026-08-18T15:35:10Z: Intune Logs/Profiles/Defender/Packages/Unified Log tabs. Tests: src/workspaces/macos-diag/MacosDiagWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +JAMF-001,macos-jamf,Detect JAMF environment,As a Mac admin I see whether JAMF Pro/Connect is present.,"Auto jamf_collect_environment. Banner Detecting / Unable (Retry) / not detected / detected. Overview cards: JAMF Pro, JAMF Connect, Environment (FDA + paths). No FDA hard-block.",Workspace open; Overview tab,src/workspaces/macos-jamf/MacosJamfWorkspace.tsx; MacosJamfOverviewTab.tsx,macos,full,jamf-store environment slice.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +JAMF-002,macos-jamf,"Policies, profiles, Self Service, Connect, log inventory",As a Mac admin I inspect JAMF activity and logs.,Policies: stats + Activity/Policies/Installs/Failures/By day/All. Profiles: jamf_filter_profiles + drilldown. Self Service table. Connect table or not-detected. Logs: Name/Path/Size inventory (does not open files).,JAMF tabs,src/workspaces/macos-jamf/MacosJamfPoliciesTab.tsx; MacosJamfProfilesTab.tsx; MacosJamfSelfServiceTab.tsx; MacosJamfConnectTab.tsx; MacosJamfLogsTab.tsx,macos,full,jamf-store tab + policies fail-keep-data.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +TL-001,timeline,Build multi-source timeline,As an analyst I merge logs into swimlanes.,"Empty: Drop log files / File > New Timeline from Folder. Empty Timeline clears. Drop unions paths. Accepts .log/.cmtlog/.evtx. File > Open / Open Folder while Timeline is active call timelineWorkspace.onOpenSource -> openTimelineSource -> buildTimelineFromSources, unioning existing sources. An IME log folder also adds the folder path so extract_ime_events can run. An empty folder is a no-op.",File New Timeline submenu; drop,src/components/timeline/TimelineWorkspace.tsx; src-tauri/src/menu.rs; src/workspaces/timeline/open-timeline-source.ts; src/workspaces/timeline/index.ts,all,both,"Log Explorer Merge into Timeline is a merged tab, not this workspace.",pass,retest,none,,code_review,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:36:43Z,"File > Open / drag-drop now call timelineWorkspace.onOpenSource -> buildTimelineFromSources, unioning existing sources. Tests: open-timeline-source.test.ts. vitest 884/884. e2e 21/21." +TL-002,timeline,"Solo/mute lanes, brush, incidents, list","As a user I isolate a source, zoom a window, and jump to incidents.","Lane chip click solos; Shift-click mutes. Brush filters entries. Incident chips set brush to incident±2s. Detail: summary, confidence, Copy anchor GUID. Combined LogListView under lanes.",LaneLegend; BrushOverlay; IncidentChipBar,src/components/timeline/LaneLegend.tsx; BrushOverlay.tsx; IncidentChipBar.tsx; IncidentDetailPanel.tsx,all,both,timeline-store solo/mute/brush/incident.,pass,test,none,,existing_test,2026-08-18T14:22:48Z,,, +DNS-001,dns-dhcp,Scan this server for DNS/DHCP logs,As a Windows DNS/DHCP admin I discover local logs.,"Scan this server: checkDnsLoggingStatus then known dns.log, DNS audit EVTX, dhcpsrvlog*.log. If debug off: Enable DNS debug logging (requires elevated). If no roles: prompt to Open files.",Empty-state Scan this server,src/workspaces/dns-dhcp/DnsDhcpWorkspace.tsx,all (live paths Windows),both,DNS empty: Scan this server.,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z, +DNS-002,dns-dhcp,Collect from domain DCs,As a domain admin I pull DNS/DHCP logs from DCs.,"Confirm: discover DCs, collect via C$ admin shares. Progress bar. Result files/size/duration per server + errors. Open collected logs parses the bundle.",Collect from domain; Open collected logs,src/workspaces/dns-dhcp/DnsDhcpWorkspace.tsx,windows domain,both,DNS empty: Collect from domain.,pass,retest,none,,ui,2026-08-18T14:22:48Z,retest_pass,2026-08-18T14:37:56Z, +DNS-003,dns-dhcp,Open DNS/DHCP files and correlate devices,As a user I load debug/audit/DHCP logs and join devices by IP.,Open files multi .log/.evtx. Rejects non-DNS/DHCP parsers. Devices keyed by IPv4. Search IP/hostname/MAC. Auto-select busiest. Query table RCODE/QTYPE filters.,Open files; File menu; log banner,src/workspaces/dns-dhcp/DnsDhcpWorkspace.tsx; DeviceList.tsx; DeviceQueryTable.tsx,all,both,DNS empty: Open files / Open DNS or DHCP logs.,pass,retest,none,,ui,,retest_pass,2026-08-18T14:37:56Z, +DEP-001,deployment,Analyze a deployment log folder,As a Windows packager I triage PSADT/MSI/Burn/PatchMyPC logs.,"Folder, known defaultPath, or parent of a file -> analyze_deployment_folder. Phases idle/analyzing/empty/error/ready. Inventory counts by format. Outcomes failed/succeeded/deferred/unknown.",File/folder/known source,src/workspaces/deployment/index.ts; DeploymentWorkspace.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Ready inventory by format and outcome counts. Tests: src/workspaces/deployment/DeploymentWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, +DEP-002,deployment,Failed cards and success tables,"As a user I expand errors and jump to the line, then scan succeeded/deferred/unclassified.",Failed cards: Open in Log Viewer (pending scroll to first Error). Expand N errors -> clickable L. Succeeded/Deferred/Unclassified tables with resizable columns.,Ready view,src/workspaces/deployment/DeploymentErrorCard.tsx; DeploymentSuccessTable.tsx,windows,full,Verified 2026-08-18T15:35:10Z: Failed cards with Open in Log Viewer; succeeded/deferred tables. Tests: src/workspaces/deployment/DeploymentWorkspace.test.tsx,pass,test,none,,existing_test,2026-08-18T15:35:10Z,,, diff --git a/e2e/fixtures/tauri-shim.ts b/e2e/fixtures/tauri-shim.ts index 1518c2bfd..832d2dd21 100644 --- a/e2e/fixtures/tauri-shim.ts +++ b/e2e/fixtures/tauri-shim.ts @@ -55,7 +55,11 @@ const DEFAULT_RESPONSES: Record = { "esp-diagnostics", ], get_known_log_sources: [], - get_file_association_prompt_status: "dismissed", + get_file_association_prompt_status: { + supported: false, + shouldPrompt: false, + isAssociated: false, + }, get_esp_elevation_state: { isElevated: false, restartSupported: true, diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index a6980249e..673fa509c 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -189,9 +189,11 @@ pub fn open_log_file( #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ParseProgressPayload { + request_id: u64, file_path: String, file_name: String, completed: u32, + global_completed: u32, total: u32, entries: u32, file_size: u64, @@ -201,6 +203,8 @@ struct ParseProgressPayload { #[tauri::command] pub fn parse_files_batch( paths: Vec, + request_id: u64, + completed_offset: u32, state: State<'_, AppState>, app: AppHandle, ) -> Result, crate::error::AppError> { @@ -244,9 +248,11 @@ pub fn parse_files_batch( let _ = app.emit( "parse-progress", ParseProgressPayload { + request_id, file_path: path.clone(), file_name, completed: done, + global_completed: completed_offset.saturating_add(done), total, entries: result.entries.len() as u32, file_size: result.file_size, @@ -266,9 +272,11 @@ pub fn parse_files_batch( let _ = app.emit( "parse-progress", ParseProgressPayload { + request_id, file_path: path.clone(), file_name, completed: done, + global_completed: completed_offset.saturating_add(done), total, entries: 0, file_size: 0, diff --git a/src/components/dialogs/AboutDialog.test.tsx b/src/components/dialogs/AboutDialog.test.tsx index 37c6b87b7..07e4db534 100644 --- a/src/components/dialogs/AboutDialog.test.tsx +++ b/src/components/dialogs/AboutDialog.test.tsx @@ -24,6 +24,14 @@ describe("AboutDialog", () => { getVersionMock.mockResolvedValue("1.3.2"); }); + it("exposes a dialog landmark", async () => { + render( {}} />); + + const dialog = await screen.findByRole("dialog", { name: "About CMTrace Open" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(await screen.findByText("CMTrace Open")).toBeVisible(); + }); + it("shows main channel app metadata", async () => { render( {}} />); diff --git a/src/components/dialogs/AboutDialog.tsx b/src/components/dialogs/AboutDialog.tsx index db01694e6..5e12f2e53 100644 --- a/src/components/dialogs/AboutDialog.tsx +++ b/src/components/dialogs/AboutDialog.tsx @@ -78,6 +78,9 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { }} >
vi.fn()); + +vi.mock("../../lib/commands", () => ({ + collectDiagnostics, +})); + +vi.mock("../../lib/log-source", () => ({ + loadPathAsLogSource: vi.fn(), +})); + +describe("CollectDiagnosticsDialog", () => { + afterEach(() => { + cleanup(); + useUiStore.setState({ collectionProgress: null, collectionResult: null }); + }); + + it("exposes a dialog landmark when open", () => { + render( {}} />); + const dialog = screen.getByRole("dialog", { name: "Collect Diagnostics" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("shows presets, category checkboxes, and starts collection", async () => { + collectDiagnostics.mockResolvedValue({ + bundlePath: "C:/Users/Public/cmtrace-bundle", + bundleId: "bundle-1", + artifactCounts: { collected: 4, missing: 1, failed: 0, total: 5 }, + durationMs: 1200, + gaps: [{ artifactId: "cbs", category: "general", reason: "not present" }], + }); + const onClose = vi.fn(); + render(); + + expect(screen.getByText("Collect Diagnostics")).toBeInTheDocument(); + expect(screen.getByText("Quick Presets")).toBeInTheDocument(); + for (const preset of COLLECTION_PRESETS) { + expect(screen.getByRole("button", { name: preset.label })).toBeInTheDocument(); + } + expect(screen.getByText("Intune & MDM")).toBeInTheDocument(); + expect(screen.getByText("Autopilot & Provisioning")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Intune + Autopilot" })); + fireEvent.click(screen.getByRole("button", { name: "Collect" })); + expect(onClose).toHaveBeenCalled(); + expect(collectDiagnostics).toHaveBeenCalled(); + }); +}); + +describe("CollectionCompleteDialog", () => { + afterEach(() => { + cleanup(); + }); + + it("exposes a dialog landmark when complete", () => { + render( + {}} + result={{ + bundlePath: "C:/Users/Public/cmtrace-bundle", + bundleId: "bundle-1", + artifactCounts: { collected: 4, missing: 1, failed: 0, total: 5 }, + durationMs: 1500, + gaps: [], + }} + />, + ); + const dialog = screen.getByRole("dialog", { name: "Collection Complete" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("traps focus and restores the opener when closed", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + const rendered = render( + {}} + result={{ + bundlePath: "C:/Users/Public/cmtrace-bundle", + bundleId: "bundle-1", + artifactCounts: { collected: 1, missing: 0, failed: 0, total: 1 }, + durationMs: 100, + gaps: [], + }} + />, + ); + const dialog = screen.getByRole("dialog", { name: "Collection Complete" }); + const close = screen.getByRole("button", { name: "Close" }); + const openBundle = screen.getByRole("button", { name: "Open Bundle" }); + + expect(dialog.contains(document.activeElement)).toBe(true); + expect(document.activeElement).toBe(close); + + openBundle.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(close); + fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(openBundle); + + opener.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(close); + + rendered.rerender( {}} result={null} />); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); + + it("shows counts, gaps, Close, and Open Bundle", () => { + const onClose = vi.fn(); + render( + , + ); + expect(screen.getByText("Collection Complete")).toBeInTheDocument(); + expect(screen.getByText("Collected")).toBeInTheDocument(); + expect(screen.getByText("Missing")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Show 1 missing/i })); + expect(screen.getByText(/CBS.log not present/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open Bundle" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/dialogs/CollectDiagnosticsDialog.tsx b/src/components/dialogs/CollectDiagnosticsDialog.tsx index 61c798e4c..773f07333 100644 --- a/src/components/dialogs/CollectDiagnosticsDialog.tsx +++ b/src/components/dialogs/CollectDiagnosticsDialog.tsx @@ -8,6 +8,7 @@ import { } from "../../lib/collection-categories"; import { collectDiagnostics } from "../../lib/commands"; import { useUiStore } from "../../stores/ui-store"; +import { useModalFocus } from "../../hooks/use-modal-focus"; interface CollectDiagnosticsDialogProps { isOpen: boolean; @@ -15,6 +16,8 @@ interface CollectDiagnosticsDialogProps { } export function CollectDiagnosticsDialog({ isOpen, onClose }: CollectDiagnosticsDialogProps) { + const dialogRef = useRef(null); + useModalFocus(isOpen, dialogRef); const setCollectionProgress = useUiStore((s) => s.setCollectionProgress); const setCollectionResult = useUiStore((s) => s.setCollectionResult); const collectingRef = useRef(false); @@ -200,6 +203,11 @@ export function CollectDiagnosticsDialog({ isOpen, onClose }: CollectDiagnostics }} >
(null); + useModalFocus(result !== null, dialogRef); const themeId = useUiStore((s) => s.themeId); const statusPalette = useMemo( () => getThemeById(themeId).severityPalette.status, @@ -53,6 +56,11 @@ export function CollectionCompleteDialog({ result, onClose }: CollectionComplete }} >
({ + inspectEvidenceBundle: vi.fn(), + inspectEvidenceArtifact: vi.fn(), +})); + +vi.mock("../../hooks/use-app-actions", () => ({ + useAppActions: () => ({ + openPathForActiveWorkspace: vi.fn(), + }), +})); + +const inspectEvidenceBundleMock = vi.mocked(inspectEvidenceBundle); +const inspectEvidenceArtifactMock = vi.mocked(inspectEvidenceArtifact); + +function metadata(overrides: Partial = {}): EvidenceBundleMetadata { + return { + manifestPath: "/tmp/bundle/manifest.json", + notesPath: "/tmp/bundle/notes.md", + evidenceRoot: "/tmp/bundle/evidence", + primaryEntryPoints: ["evidence/ime.log"], + availablePrimaryEntryPoints: ["evidence/ime.log"], + bundleId: "bundle-fixture", + bundleLabel: "Fixture evidence bundle", + createdUtc: "2026-08-18T12:00:00Z", + caseReference: "CASE-018", + summary: "Minimal fixture inventory.", + collectorProfile: "quick", + collectorVersion: "1.0.0", + collectedUtc: "2026-08-18T12:00:00Z", + deviceName: "TEST-PC", + primaryUser: "analyst", + platform: "windows", + osVersion: "10.0.26100", + tenant: "contoso", + artifactCounts: { + collected: 0, + missing: 0, + failed: 0, + skipped: 0, + }, + ...overrides, + }; +} + +function details(overrides: Partial = {}): EvidenceBundleDetails { + const meta = overrides.metadata ?? metadata(); + return { + bundleRootPath: "/tmp/bundle", + metadata: meta, + manifestContent: '{"bundleId":"bundle-fixture"}', + notesContent: "Collector notes.", + artifacts: [], + expectedEvidence: [], + observedGaps: [], + priorityQuestions: [], + handoffSummary: null, + ...overrides, + }; +} + +function tabButton(container: HTMLElement, name: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === name, + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Missing tab button: ${name}`); + } + return button; +} + +describe("EvidenceBundleDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + useUiStore.setState({ + activeView: "log", + activeWorkspace: "log", + }); + useLogStore.getState().clear(); + inspectEvidenceBundleMock.mockResolvedValue(details()); + inspectEvidenceArtifactMock.mockResolvedValue({ + path: "/tmp/bundle/evidence/ime.log", + intakeKind: "log", + summary: "log preview", + registrySnapshot: null, + eventLogExport: null, + }); + }); + + it("renders nothing without bundle metadata", () => { + render( {}} />); + expect(screen.queryByText("Evidence Bundle")).not.toBeInTheDocument(); + expect(inspectEvidenceBundleMock).not.toHaveBeenCalled(); + }); + + it("opens Summary/Inventory/Notes/Manifest and empty inventory copy from a store fixture", async () => { + useLogStore.getState().setBundleMetadata(metadata()); + useLogStore.getState().setActiveSource({ + kind: "folder", + path: "/tmp/bundle", + }); + + const { container } = render( {}} />); + + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-label", "Evidence bundle summary"); + expect(screen.getByText("Fixture evidence bundle")).toBeInTheDocument(); + expect(screen.getByText("Minimal fixture inventory.")).toBeInTheDocument(); + expect(tabButton(container, "Summary")).toHaveAttribute("aria-pressed", "true"); + expect(tabButton(container, "Inventory")).toBeTruthy(); + expect(tabButton(container, "Notes")).toBeTruthy(); + expect(tabButton(container, "Manifest")).toBeTruthy(); + expect(screen.getByText("Bundle metadata")).toBeInTheDocument(); + expect(screen.getByText("Primary evidence entry points")).toBeInTheDocument(); + expect(tabButton(container, "Close")).toBeTruthy(); + + await waitFor(() => { + expect(inspectEvidenceBundleMock).toHaveBeenCalledWith("/tmp/bundle"); + }); + await waitFor(() => { + expect(screen.queryByText("Loading evidence bundle details...")).not.toBeInTheDocument(); + }); + + fireEvent.click(tabButton(container, "Inventory")); + expect(tabButton(container, "Inventory")).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByText("No artifact records were found in the manifest."), + ).toBeInTheDocument(); + expect( + screen.getByText("No intake diagnostics are available yet."), + ).toBeInTheDocument(); + expect(screen.getByText("No artifact detail was available.")).toBeInTheDocument(); + expect( + screen.getByText("No expected-evidence detail was recorded in the manifest."), + ).toBeInTheDocument(); + + fireEvent.click(tabButton(container, "Notes")); + expect(tabButton(container, "Notes")).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Collector notes.")).toBeInTheDocument(); + + fireEvent.click(tabButton(container, "Manifest")); + expect(tabButton(container, "Manifest")).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText('{"bundleId":"bundle-fixture"}')).toBeInTheDocument(); + }); + + it("shows inspect error copy while keeping the dialog and tabs visible", async () => { + useLogStore.getState().setBundleMetadata(metadata()); + useLogStore.getState().setActiveSource({ + kind: "folder", + path: "/tmp/bundle", + }); + inspectEvidenceBundleMock.mockRejectedValue(new Error("inspect failed")); + + const { container } = render( {}} />); + + expect(await screen.findByText("inspect failed")).toBeInTheDocument(); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(tabButton(container, "Summary")).toBeTruthy(); + expect(tabButton(container, "Inventory")).toBeTruthy(); + expect(tabButton(container, "Notes")).toBeTruthy(); + expect(tabButton(container, "Manifest")).toBeTruthy(); + + fireEvent.click(tabButton(container, "Notes")); + expect(screen.getByText("No content was available for this file.")).toBeInTheDocument(); + }); +}); diff --git a/src/components/dialogs/FileAssociationPromptDialog.test.tsx b/src/components/dialogs/FileAssociationPromptDialog.test.tsx new file mode 100644 index 000000000..7447fd0cb --- /dev/null +++ b/src/components/dialogs/FileAssociationPromptDialog.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + associateLogFilesWithApp, + setFileAssociationPromptSuppressed, +} from "../../lib/commands"; +import { FileAssociationPromptDialog } from "./FileAssociationPromptDialog"; + +vi.mock("../../lib/commands", () => ({ + associateLogFilesWithApp: vi.fn(), + setFileAssociationPromptSuppressed: vi.fn(), +})); + +describe("FileAssociationPromptDialog", () => { + let opener: HTMLButtonElement; + + beforeEach(() => { + vi.clearAllMocks(); + opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + }); + + afterEach(() => { + opener.remove(); + }); + it("traps focus and restores the opener when closed", () => { + vi.mocked(associateLogFilesWithApp).mockResolvedValue(undefined); + vi.mocked(setFileAssociationPromptSuppressed).mockResolvedValue(undefined); + + + const rendered = render( + {}} />, + ); + const dialog = screen.getByRole("dialog"); + const buttons = within(dialog).getAllByRole("button"); + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + expect(document.activeElement).toBe(first); + + last.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(first); + fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(last); + + opener.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(first); + + opener.focus(); + fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(last); + rendered.rerender( + {}} />, + ); + expect(document.activeElement).toBe(opener); + }); + + it("keeps focus on the dialog surface while submission disables its controls", () => { + vi.mocked(associateLogFilesWithApp).mockReturnValue( + new Promise(() => {}), + ); + + + const rendered = render( + {}} />, + ); + const dialog = screen.getByRole("dialog"); + const associate = within(dialog).getByRole("button", { name: "Associate" }); + + associate.focus(); + fireEvent.click(associate); + + expect(associate).toBeDisabled(); + expect(document.activeElement).toBe(dialog); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(dialog); + + rendered.rerender( + {}} />, + ); + expect(document.activeElement).toBe(opener); + }); +}); diff --git a/src/components/dialogs/FileAssociationPromptDialog.tsx b/src/components/dialogs/FileAssociationPromptDialog.tsx index 463dc66a9..672c106b0 100644 --- a/src/components/dialogs/FileAssociationPromptDialog.tsx +++ b/src/components/dialogs/FileAssociationPromptDialog.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { associateLogFilesWithApp, setFileAssociationPromptSuppressed, } from "../../lib/commands"; import { tokens } from "@fluentui/react-components"; +import { useModalFocus } from "../../hooks/use-modal-focus"; interface FileAssociationPromptDialogProps { isOpen: boolean; @@ -24,6 +25,13 @@ export function FileAssociationPromptDialog({ }: FileAssociationPromptDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const dialogRef = useRef(null); + useModalFocus( + isOpen, + dialogRef, + undefined, + isSubmitting ? "submitting" : "idle", + ); useEffect(() => { if (!isOpen) { @@ -95,6 +103,11 @@ export function FileAssociationPromptDialog({ }} >
{ + useFilterStore.getState().clearFilter(); + }); + +describe("FilterDialog", () => { + it("exposes a dialog landmark when open", () => { + render( + {}} + onApply={async () => undefined} + currentClauses={[]} + />, + ); + + const dialog = screen.getByRole("dialog", { name: "Filter" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("traps focus and restores the opener when closed", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + const rendered = render( + {}} + onApply={async () => undefined} + currentClauses={[]} + />, + ); + const dialog = screen.getByRole("dialog", { name: "Filter" }); + const input = dialog.querySelector("input"); + const tabWrapTarget = dialog.querySelector("select"); + const close = screen.getByRole("button", { name: "Cancel" }); + + expect(input).not.toBeNull(); + expect(tabWrapTarget).not.toBeNull(); + expect(document.activeElement).toBe(input); + + close.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(tabWrapTarget); + fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(close); + + opener.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(tabWrapTarget); + + rendered.rerender( + {}} + onApply={async () => undefined} + currentClauses={[]} + />, + ); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); + + it("returns focus to the first clause when a focused remove control unmounts", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + const rendered = render( + {}} + onApply={async () => undefined} + currentClauses={[ + { field: "Message", op: "Contains", value: "first" }, + { field: "Component", op: "Equals", value: "second" }, + ]} + />, + ); + const dialog = screen.getByRole("dialog", { name: "Filter" }); + const input = dialog.querySelector("input"); + const selects = dialog.querySelectorAll("select"); + const removeButtons = screen.getAllByRole("button", { + name: "Remove clause", + }); + + expect(input).not.toBeNull(); + expect(selects.length).toBe(4); + expect(removeButtons.length).toBe(2); + + const focusedRemoveButton = removeButtons[1]; + focusedRemoveButton.focus(); + fireEvent.click(focusedRemoveButton); + + expect(document.activeElement).toBe(input); + dialog.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(selects[0]); + + rendered.rerender( + {}} + onApply={async () => undefined} + currentClauses={[]} + />, + ); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); + it("moves focus to the dialog surface when filtering disables controls", () => { + render( + {}} + onApply={async () => undefined} + currentClauses={[]} + />, + ); + const dialog = screen.getByRole("dialog", { name: "Filter" }); + const cancel = screen.getByRole("button", { name: "Cancel" }); + + cancel.focus(); + expect(document.activeElement).toBe(cancel); + + act(() => { + useFilterStore.getState().setIsFiltering(true); + }); + + expect(document.activeElement).toBe(dialog); + }); +}); diff --git a/src/components/dialogs/FilterDialog.tsx b/src/components/dialogs/FilterDialog.tsx index f1cc54d87..a6d330fb3 100644 --- a/src/components/dialogs/FilterDialog.tsx +++ b/src/components/dialogs/FilterDialog.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { tokens } from "@fluentui/react-components"; import { DismissRegular } from "@fluentui/react-icons"; import { useFilterStore } from "../../stores/filter-store"; +import { useModalFocus } from "../../hooks/use-modal-focus"; export type FilterOp = | "Equals" @@ -55,9 +56,16 @@ export function FilterDialog({ }: FilterDialogProps) { const [clauses, setClauses] = useState([emptyClause()]); const inputRef = useRef(null); + const dialogRef = useRef(null); const isFiltering = useFilterStore((s) => s.isFiltering); const filterError = useFilterStore((s) => s.filterError); + useModalFocus( + isOpen, + dialogRef, + inputRef, + `${clauses.length}:${isFiltering ? "filtering" : "ready"}`, + ); useEffect(() => { if (isOpen) { @@ -66,7 +74,6 @@ export function FilterDialog({ ? [...currentClauses] : [emptyClause()] ); - setTimeout(() => inputRef.current?.focus(), 50); } }, [isOpen, currentClauses]); @@ -168,6 +175,11 @@ export function FilterDialog({ }} >
({ - AppearanceTab: () =>
Appearance settings
, +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue({ families: [] }), +})); + +vi.mock("@tauri-apps/api/app", () => ({ + getVersion: vi.fn().mockResolvedValue("1.3.1"), +})); + +vi.mock("../../lib/commands", () => ({ + getUpdatePolicy: vi.fn().mockResolvedValue({ + updateChecksDisabledByPolicy: false, + }), })); describe("SettingsDialog tab keyboard navigation", () => { @@ -13,6 +23,14 @@ describe("SettingsDialog tab keyboard navigation", () => { useUiStore.setState({ currentPlatform: "macos" }); }); + it("exposes a dialog landmark", () => { + render( {}} />); + expect(screen.getByRole("dialog", { name: "Settings" })).toHaveAttribute( + "aria-modal", + "true", + ); + }); + it("keeps only the selected tab in the page tab order", () => { render( {}} />); @@ -51,4 +69,33 @@ describe("SettingsDialog tab keyboard navigation", () => { expect(columnsTab).toHaveAttribute("aria-selected", "false"); expect(columnsTab).toHaveAttribute("tabindex", "-1"); }); + + it("mounts Appearance, Columns, Behavior, and Updates contracts when those tabs are selected", async () => { + render( {}} />); + + expect( + screen.getByRole("combobox", { name: "Select application theme" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Reset Defaults" })).toBeVisible(); + expect( + await screen.findByRole("button", { name: "Default (System)" }), + ).toBeVisible(); + + fireEvent.click(screen.getByRole("tab", { name: "Columns" })); + expect(screen.getByText("Using default column order and widths.")).toBeVisible(); + + fireEvent.click(screen.getByRole("tab", { name: "Behavior" })); + expect( + screen.getByRole("checkbox", { name: /show info pane by default/i }), + ).toBeVisible(); + expect( + screen.getByRole("checkbox", { name: /confirm before closing tabs/i }), + ).toBeVisible(); + + fireEvent.click(screen.getByRole("tab", { name: "Updates" })); + expect( + screen.getByRole("checkbox", { name: /check for updates on startup/i }), + ).toBeVisible(); + expect(await screen.findByText("Main channel")).toBeVisible(); + }); }); diff --git a/src/components/dialogs/UpdateDialog.test.tsx b/src/components/dialogs/UpdateDialog.test.tsx new file mode 100644 index 000000000..94b50f892 --- /dev/null +++ b/src/components/dialogs/UpdateDialog.test.tsx @@ -0,0 +1,206 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { UpdateInfo } from "../../hooks/use-update-checker"; +import { UpdateDialog } from "./UpdateDialog"; + +function renderDialog( + overrides: { + isOpen?: boolean; + onClose?: () => void; + updateInfo?: UpdateInfo | null; + isChecking?: boolean; + isDownloading?: boolean; + downloadProgress?: number; + onCheckForUpdates?: () => Promise; + onDownloadAndInstall?: () => void; + onOpenReleasePage?: () => void; + onSkipVersion?: (version: string) => void; + } = {}, +) { + const props = { + isOpen: true, + onClose: vi.fn(), + updateInfo: null as UpdateInfo | null, + isChecking: false, + isDownloading: false, + downloadProgress: 0, + onCheckForUpdates: vi.fn().mockResolvedValue(null), + onDownloadAndInstall: vi.fn(), + onOpenReleasePage: vi.fn(), + onSkipVersion: vi.fn(), + ...overrides, + }; + const rendered = render(); + return { props, ...rendered }; +} + +const availableUpdate = (overrides: Partial = {}): UpdateInfo => ({ + available: true, + currentVersion: "1.3.1", + newVersion: "1.3.2", + updateChannel: "stable", + canAutoUpdate: true, + releaseNotes: "Bug fixes", + ...overrides, +}); + +describe("UpdateDialog", () => { + it("exposes a dialog landmark when open", () => { + renderDialog({ isChecking: true }); + const dialog = screen.getByRole("dialog", { name: "Check for Updates" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("traps focus and restores the opener when closed", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + const { rerender } = renderDialog({ isChecking: true }); + const dialog = screen.getByRole("dialog", { name: "Check for Updates" }); + const cancel = screen.getByRole("button", { name: "Cancel" }); + + expect(dialog.contains(document.activeElement)).toBe(true); + expect(document.activeElement).toBe(cancel); + + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(cancel); + fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(cancel); + + opener.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(cancel); + + rerender( + , + ); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); + it("restores focus when update content removes the focused control", () => { + const { props, rerender } = renderDialog({ + updateInfo: availableUpdate(), + }); + const dialog = screen.getByRole("dialog", { name: "Check for Updates" }); + const download = screen.getByRole("button", { + name: "Download & install", + }); + download.focus(); + + rerender( + , + ); + + expect(dialog.contains(document.activeElement)).toBe(true); + expect(document.activeElement).toBe(dialog); + }); + + it("shows Cancel while checking", () => { + const { props } = renderDialog({ isChecking: true }); + + expect(screen.getByText("Check for Updates")).toBeVisible(); + expect(screen.getByText("Checking for updates...")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + + it("shows Skip, Later, and Download & install when an auto-update is available", () => { + const { props } = renderDialog({ updateInfo: availableUpdate() }); + + expect(screen.getByText("Update Available")).toBeVisible(); + expect(screen.getByText(/Main channel/)).toBeVisible(); + expect(screen.getByText("Bug fixes")).toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "Skip this version" })); + expect(props.onSkipVersion).toHaveBeenCalledWith("1.3.2"); + + fireEvent.click(screen.getByRole("button", { name: "Later" })); + expect(props.onClose).toHaveBeenCalledOnce(); + + fireEvent.click(screen.getByRole("button", { name: "Download & install" })); + expect(props.onDownloadAndInstall).toHaveBeenCalledOnce(); + }); + + it("offers GitHub download when auto-update is unavailable", () => { + const { props } = renderDialog({ + updateInfo: availableUpdate({ canAutoUpdate: false }), + }); + + fireEvent.click(screen.getByRole("button", { name: "Download from GitHub..." })); + expect(props.onOpenReleasePage).toHaveBeenCalledOnce(); + expect( + screen.queryByRole("button", { name: "Download & install" }), + ).not.toBeInTheDocument(); + }); + + it("shows download percent and ignores Escape while downloading", () => { + const { props } = renderDialog({ + isDownloading: true, + downloadProgress: 0.42, + }); + + expect(screen.getByText("Downloading Update")).toBeVisible(); + expect(screen.getByText("42%")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(props.onClose).not.toHaveBeenCalled(); + }); + + it("shows OK when the app is up to date", () => { + const { props } = renderDialog({ + updateInfo: { + available: false, + currentVersion: "1.3.1", + updateChannel: "stable", + canAutoUpdate: true, + }, + }); + + expect( + screen.getByText("You're running the latest version (v1.3.1)."), + ).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + + it("shows OK when the check fails", () => { + const { props } = renderDialog({ + updateInfo: { + available: false, + currentVersion: "1.3.1", + updateChannel: "stable", + canAutoUpdate: true, + error: "network down", + }, + }); + + expect( + screen.getByText("Unable to check for updates: network down"), + ).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + + it("does not render when closed", () => { + renderDialog({ isOpen: false, isChecking: true }); + expect(screen.queryByText("Check for Updates")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/dialogs/UpdateDialog.tsx b/src/components/dialogs/UpdateDialog.tsx index 457a65741..63adb21cb 100644 --- a/src/components/dialogs/UpdateDialog.tsx +++ b/src/components/dialogs/UpdateDialog.tsx @@ -1,6 +1,7 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { tokens } from "@fluentui/react-components"; import type { UpdateInfo } from "../../hooks/use-update-checker"; +import { useModalFocus } from "../../hooks/use-modal-focus"; import { getUpdateChannelLabel } from "../../lib/update-channel"; interface UpdateDialogProps { @@ -28,6 +29,16 @@ export function UpdateDialog({ onOpenReleasePage, onSkipVersion, }: UpdateDialogProps) { + const dialogRef = useRef(null); + const focusKey = isChecking + ? "checking" + : isDownloading + ? "downloading" + : updateInfo?.available + ? "available" + : "idle"; + + useModalFocus(isOpen, dialogRef, undefined, focusKey); useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { @@ -241,7 +252,14 @@ export function UpdateDialog({ if (e.target === e.currentTarget && !isDownloading) onClose(); }} > -
+
{renderContent()}
diff --git a/src/components/dialogs/settings/AppearanceTab.test.tsx b/src/components/dialogs/settings/AppearanceTab.test.tsx new file mode 100644 index 000000000..7d0cb6860 --- /dev/null +++ b/src/components/dialogs/settings/AppearanceTab.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_LOG_DETAILS_FONT_SIZE, + DEFAULT_LOG_LIST_FONT_SIZE, +} from "../../../lib/log-accessibility"; +import { DEFAULT_THEME_ID } from "../../../lib/themes"; +import { useUiStore } from "../../../stores/ui-store"; +import { AppearanceTab } from "./AppearanceTab"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const invokeMock = vi.mocked(invoke); + +describe("AppearanceTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + invokeMock.mockResolvedValue({ families: ["Consolas", "Segoe UI"] }); + useUiStore.setState({ + themeId: DEFAULT_THEME_ID, + logListFontSize: DEFAULT_LOG_LIST_FONT_SIZE, + logDetailsFontSize: DEFAULT_LOG_DETAILS_FONT_SIZE, + fontFamily: null, + }); + }); + + it("shows theme, size sliders, Default (System) font, preview, and Reset Defaults", async () => { + render(); + + const themeSelect = screen.getByRole("combobox", { + name: "Select application theme", + }); + expect(themeSelect).toBeVisible(); + expect(screen.getByRole("option", { name: "Classic CMTrace" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "Light" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "Dark" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "Dracula" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "Nord" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "Solarized Dark" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "High Contrast" })).toBeEnabled(); + expect(screen.getByRole("option", { name: "Hot Dog Stand" })).toBeEnabled(); + + expect(screen.getByText("Application text size")).toBeVisible(); + expect(screen.getByText("Details pane text size")).toBeVisible(); + expect(screen.getAllByRole("slider")).toHaveLength(2); + expect( + screen.getByRole("slider", { + name: `Application text size: ${DEFAULT_LOG_LIST_FONT_SIZE} pixels`, + }), + ).toHaveValue(String(DEFAULT_LOG_LIST_FONT_SIZE)); + + expect(screen.getByText("Font family")).toBeVisible(); + expect( + await screen.findByRole("button", { name: "Default (System)" }), + ).toBeVisible(); + expect(await screen.findByRole("button", { name: "Consolas" })).toBeVisible(); + + expect(screen.getByText("Preview")).toBeVisible(); + expect(screen.getByText(/Preview message row/)).toBeVisible(); + expect( + screen.getByText("The details pane preview uses its own independent reading size."), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Reset Defaults" }), + ).toBeVisible(); + }); + + it("applies theme, font, and Reset Defaults through the store", async () => { + render(); + + fireEvent.change(screen.getByRole("combobox", { name: "Select application theme" }), { + target: { value: "classic-cmtrace" }, + }); + expect(useUiStore.getState().themeId).toBe("classic-cmtrace"); + + fireEvent.click(await screen.findByRole("button", { name: "Consolas" })); + expect(useUiStore.getState().fontFamily).toBe("Consolas"); + expect(screen.getByText("Selected: Consolas")).toBeVisible(); + + fireEvent.change( + screen.getByRole("slider", { + name: `Application text size: ${DEFAULT_LOG_LIST_FONT_SIZE} pixels`, + }), + { target: { value: "16" } }, + ); + expect(useUiStore.getState().logListFontSize).toBe(16); + + fireEvent.click(screen.getByRole("button", { name: "Reset Defaults" })); + expect(useUiStore.getState()).toMatchObject({ + themeId: DEFAULT_THEME_ID, + logListFontSize: DEFAULT_LOG_LIST_FONT_SIZE, + logDetailsFontSize: DEFAULT_LOG_DETAILS_FONT_SIZE, + fontFamily: null, + }); + + await waitFor(() => { + expect(screen.queryByText("Selected: Consolas")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/dialogs/settings/BehaviorTab.test.tsx b/src/components/dialogs/settings/BehaviorTab.test.tsx new file mode 100644 index 000000000..452b2b6f7 --- /dev/null +++ b/src/components/dialogs/settings/BehaviorTab.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useUiStore } from "../../../stores/ui-store"; +import { BehaviorTab } from "./BehaviorTab"; + +describe("BehaviorTab", () => { + beforeEach(() => { + useUiStore.setState({ + defaultShowInfoPane: true, + confirmTabClose: false, + }); + }); + + it("shows the info-pane and tab-close checkboxes", () => { + render(); + + const infoPane = screen.getByRole("checkbox", { + name: /show info pane by default/i, + }); + const confirmClose = screen.getByRole("checkbox", { + name: /confirm before closing tabs/i, + }); + + expect(infoPane).toBeChecked(); + expect(confirmClose).not.toBeChecked(); + }); + + it("writes checkbox changes to the store", () => { + render(); + + fireEvent.click( + screen.getByRole("checkbox", { name: /show info pane by default/i }), + ); + fireEvent.click( + screen.getByRole("checkbox", { name: /confirm before closing tabs/i }), + ); + + expect(useUiStore.getState().defaultShowInfoPane).toBe(false); + expect(useUiStore.getState().confirmTabClose).toBe(true); + }); +}); diff --git a/src/components/dialogs/settings/ColumnsTab.test.tsx b/src/components/dialogs/settings/ColumnsTab.test.tsx new file mode 100644 index 000000000..118b238a0 --- /dev/null +++ b/src/components/dialogs/settings/ColumnsTab.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useUiStore } from "../../../stores/ui-store"; +import { ColumnsTab } from "./ColumnsTab"; + +describe("ColumnsTab", () => { + beforeEach(() => { + useUiStore.setState({ + columnOrder: null, + columnWidths: {}, + }); + }); + + it("reports default column order and widths", () => { + render(); + + expect( + screen.getByText("Using default column order and widths."), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Reset to Defaults" }), + ).not.toBeInTheDocument(); + }); + + it("reports custom order and widths and Reset to Defaults clears them", () => { + useUiStore.setState({ + columnOrder: ["dateTime", "message", "component"], + columnWidths: { message: 420, component: 160 }, + }); + + render(); + + expect(screen.getByText("Custom column order is active.")).toBeVisible(); + expect( + screen.getByText("Custom column widths are active (2 columns)."), + ).toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "Reset to Defaults" })); + + expect(useUiStore.getState().columnOrder).toBeNull(); + expect(useUiStore.getState().columnWidths).toEqual({}); + expect( + screen.getByText("Using default column order and widths."), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Reset to Defaults" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/dialogs/settings/FileAssociationsTab.test.tsx b/src/components/dialogs/settings/FileAssociationsTab.test.tsx new file mode 100644 index 000000000..772378b60 --- /dev/null +++ b/src/components/dialogs/settings/FileAssociationsTab.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FileAssociationsTab } from "./FileAssociationsTab"; +import { FileAssociationPromptDialog } from "../FileAssociationPromptDialog"; +import { useUiStore } from "../../../stores/ui-store"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async () => ({ supported: true, shouldPrompt: false, isAssociated: false })), +})); + +describe("FileAssociationsTab", () => { + beforeEach(() => { + useUiStore.setState(useUiStore.getInitialState(), true); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows a static note off Windows", () => { + useUiStore.setState({ currentPlatform: "macos" }); + render(); + expect( + screen.getByText(/File associations are only available on Windows/), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Associate \.log files with CMTrace Open/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Re-register associations/ }), + ).not.toBeInTheDocument(); + }); + + it("offers Associate on Windows when not registered", async () => { + useUiStore.setState({ currentPlatform: "windows" }); + render(); + expect( + await screen.findByRole("button", { name: /Associate \.log files with CMTrace Open/ }), + ).toBeInTheDocument(); + }); +}); + +describe("FileAssociationPromptDialog", () => { + afterEach(() => { + cleanup(); + }); + + it("exposes a dialog landmark when open", () => { + render( {}} />); + const dialog = screen.getByRole("dialog", { name: "Associate log files with CMTrace Open?" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("offers Associate, Don't Ask Again, and Ask Later", () => { + render(); + expect(screen.getByText(/Associate log files with CMTrace Open/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Associate" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Don't Ask Again" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ask Later" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/dialogs/settings/GraphApiTab.test.tsx b/src/components/dialogs/settings/GraphApiTab.test.tsx index 99f3383da..a88b94e29 100644 --- a/src/components/dialogs/settings/GraphApiTab.test.tsx +++ b/src/components/dialogs/settings/GraphApiTab.test.tsx @@ -1434,4 +1434,15 @@ describe("GraphApiTab delegated capabilities", () => { screen.queryByText("Connected with partial permissions"), ).not.toBeInTheDocument(); }); + + it("shows an unavailable note off Windows", () => { + useUiStore.setState({ currentPlatform: "macos" }); + render(); + expect( + screen.getByText(/Graph API integration is only available on Windows/), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Sign in with Windows" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/components/dialogs/settings/UpdatesTab.test.tsx b/src/components/dialogs/settings/UpdatesTab.test.tsx index 1b8299d63..cc7dd99af 100644 --- a/src/components/dialogs/settings/UpdatesTab.test.tsx +++ b/src/components/dialogs/settings/UpdatesTab.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { getVersion } from "@tauri-apps/api/app"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getUpdatePolicy } from "../../../lib/commands"; @@ -70,4 +70,19 @@ describe("UpdatesTab", () => { screen.getByText("Update checks are disabled by managed policy on this device.") ).toBeVisible(); }); + + it("shows the channel badge and clears a skipped version", async () => { + localStorage.setItem("cmtraceopen-skipped-update-version", "1.3.2"); + + render(); + + expect(await screen.findByText("Main channel")).toBeVisible(); + expect(screen.getByText("v1.3.2 is being skipped")).toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + + expect(localStorage.getItem("cmtraceopen-skipped-update-version")).toBeNull(); + expect(screen.queryByText("v1.3.2 is being skipped")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); + }); }); diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 2945fbe41..b7980fea5 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -309,9 +309,6 @@ export function AppShell() { const tabs = useUiStore.getState().openTabs; if (activeTabIndex < 0 || activeTabIndex >= tabs.length) return; const tab = tabs[activeTabIndex]; - const currentPath = useLogStore.getState().openFilePath; - if (currentPath === tab.filePath) return; - useUiStore.getState().ensureLogViewVisible("tab-switch"); switchToTab(tab.filePath, tab.sourceContext).catch((err) => { console.error("[tab-switch] failed to load", tab.filePath, err); diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index 930db4704..9d854172b 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -203,8 +203,10 @@ export function LogSidebar() { clearFilter(); try { - await loadSelectedLogFile(path, activeSource); - setLastFailedPath(null); + const result = await loadSelectedLogFile(path, activeSource); + if (result !== null) { + setLastFailedPath(null); + } } catch (error) { setLastFailedPath(path); setErrorMessage( @@ -228,10 +230,12 @@ export function LogSidebar() { clearFilter(); try { - await loadLogSource(activeSource, { + const result = await loadLogSource(activeSource, { selectedFilePath: activeFilePath, }); - setLastFailedPath(null); + if (result !== null) { + setLastFailedPath(null); + } } catch (error) { setRefreshErrorMessage( error instanceof Error ? error.message : "Failed to reload source." diff --git a/src/components/layout/StatusBar.folder-progress.test.tsx b/src/components/layout/StatusBar.folder-progress.test.tsx new file mode 100644 index 000000000..b3649a907 --- /dev/null +++ b/src/components/layout/StatusBar.folder-progress.test.tsx @@ -0,0 +1,124 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type ProgressPayload = { + requestId: number; + filePath: string; + fileName: string; + completed: number; + total: number; + globalCompleted: number; + entries: number; + fileSize: number; + parseMs: number; +}; +type ProgressEvent = { payload: ProgressPayload }; +const progressEvents = vi.hoisted(() => ({ + listener: null as ((event: ProgressEvent) => void) | null, + listen: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: progressEvents.listen, +})); + +vi.mock("../../workspaces/event-log/evtx-store", () => ({ + useEvtxStore: (selector: (state: { + records: unknown[]; + sourceMode: string; + isLoading: boolean; + loadedChannels: Set; + loadElapsedMs: number; + }) => unknown) => + selector({ + records: [], + sourceMode: "idle", + isLoading: false, + loadedChannels: new Set(), + loadElapsedMs: 0, + }), +})); + +import { StatusBar } from "./StatusBar"; +import { useParseProgressListener } from "../../hooks/use-parse-progress-listener"; +import { useLogStore } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { useFilterStore } from "../../stores/filter-store"; + +function ParseProgressListenerHarness() { + useParseProgressListener(); + return null; +} + +describe("StatusBar folder parse progress", () => { + beforeEach(() => { + progressEvents.listener = null; + progressEvents.listen.mockReset(); + progressEvents.listen.mockImplementation( + (_event: string, callback: (event: ProgressEvent) => void) => { + progressEvents.listener = callback; + return Promise.resolve(() => undefined); + }, + ); + useLogStore.getState().clear(); + useFilterStore.setState(useFilterStore.getInitialState(), true); + useUiStore.setState(useUiStore.getInitialState(), true); + useUiStore.setState({ activeView: "log", activeWorkspace: "log" }); + useLogStore.getState().setFolderLoadProgress({ + current: 3, + total: 10, + currentFile: "AppEnforce.log", + }); + useLogStore.getState().setFolderLoadRequestId(42); + }); + + afterEach(() => { + cleanup(); + }); + + it("ignores progress events from a different folder-load request", async () => { + render(); + await vi.waitFor(() => expect(progressEvents.listener).not.toBeNull()); + + act(() => { + progressEvents.listener?.({ + payload: { + requestId: 41, + filePath: "stale.log", + fileName: "stale.log", + completed: 9, + globalCompleted: 9, + total: 10, + entries: 1, + fileSize: 1, + parseMs: 1, + }, + }); + }); + expect(useLogStore.getState().folderLoadCompletedFiles).toBe(3); + expect(useLogStore.getState().folderLoadCurrentFile).toBe("AppEnforce.log"); + + act(() => { + progressEvents.listener?.({ + payload: { + requestId: 42, + filePath: "Accepted.log", + fileName: "Accepted.log", + completed: 4, + globalCompleted: 4, + total: 10, + entries: 1, + fileSize: 1, + parseMs: 1, + }, + }); + }); + expect(useLogStore.getState().folderLoadCompletedFiles).toBe(4); + expect(useLogStore.getState().folderLoadCurrentFile).toBe("Accepted.log"); + }); + + it("shows N of M and the current file while a folder load is in progress", () => { + render(); + expect(screen.getByText(/Parsing 3 of 10 files — AppEnforce.log/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/Toolbar.dsregcmd.test.tsx b/src/components/layout/Toolbar.dsregcmd.test.tsx new file mode 100644 index 000000000..1116bac61 --- /dev/null +++ b/src/components/layout/Toolbar.dsregcmd.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const pasteDsregcmdSource = vi.fn(async () => undefined); +const captureDsregcmdSource = vi.fn(async () => undefined); + +vi.mock("../../hooks/use-app-actions", () => ({ + useAppActions: () => ({ + commandState: { + canOpenSources: true, + canOpenKnownSources: false, + canPauseResume: false, + canFind: false, + hasFindSession: false, + canFilter: false, + canRefresh: false, + canToggleSidebar: true, + canToggleDetailsPane: false, + canToggleInfoPane: false, + canAdjustTextSize: false, + canShowEvidenceBundle: false, + canSaveSession: false, + canCollectDiagnostics: true, + isLoading: false, + isPaused: false, + hasActiveSource: false, + isSidebarVisible: true, + isDetailsVisible: false, + isInfoPaneVisible: false, + activeFilterCount: 0, + isFiltering: false, + filterError: null, + activeWorkspace: "dsregcmd", + openFileLabel: "Open text file...", + openFolderLabel: "Open evidence folder...", + }, + openSourceFileDialog: vi.fn(), + openSourceFolderDialog: vi.fn(), + openKnownSourceCatalogAction: vi.fn(), + pasteDsregcmdSource, + captureDsregcmdSource, + showFilterDialog: vi.fn(), + showErrorLookupDialog: vi.fn(), + toggleDetailsPane: vi.fn(), + toggleInfoPane: vi.fn(), + switchWorkspace: vi.fn(), + }), +})); + +vi.mock("../../lib/log-source", () => ({ + loadFilesAsLogSource: vi.fn(), + refreshKnownLogSources: vi.fn(async () => undefined), +})); + +vi.mock("@tauri-apps/plugin-os", () => ({ + platform: () => "windows", +})); + +import { Toolbar } from "./Toolbar"; +import { useUiStore } from "../../stores/ui-store"; +import { useLogStore } from "../../stores/log-store"; + +describe("Toolbar dsregcmd paste and capture", () => { + beforeEach(() => { + pasteDsregcmdSource.mockClear(); + captureDsregcmdSource.mockClear(); + useLogStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); + useUiStore.setState({ + activeWorkspace: "dsregcmd", + activeView: "dsregcmd", + currentPlatform: "windows", + enabledWorkspaces: null, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("adds Paste clipboard and Capture live output when dsregcmd is active", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Open dsregcmd source/i })); + fireEvent.click(await screen.findByText("Paste clipboard")); + expect(pasteDsregcmdSource).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: /Open dsregcmd source/i })); + fireEvent.click(await screen.findByText("Capture live output")); + expect(captureDsregcmdSource).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/log-view/DnsWorkspaceBanner.test.tsx b/src/components/log-view/DnsWorkspaceBanner.test.tsx new file mode 100644 index 000000000..583a15846 --- /dev/null +++ b/src/components/log-view/DnsWorkspaceBanner.test.tsx @@ -0,0 +1,94 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DnsWorkspaceBanner } from "./DnsWorkspaceBanner"; +import { useLogStore } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { useDnsDhcpStore } from "../../workspaces/dns-dhcp/dns-dhcp-store"; +import type { LogEntry, ParserSelectionInfo } from "../../types/log"; + +const parser: ParserSelectionInfo = { + parser: "dnsDebug", + implementation: "dnsDebug", + provenance: "dedicated", + parseQuality: "structured", + recordFraming: "logicalRecord", + dateOrder: null, +}; + +function dnsEntry(): LogEntry { + return { + id: 1, + lineNumber: 1, + message: "QUERY A contoso.local", + component: null, + timestamp: Date.parse("2026-07-26T12:00:00Z"), + timestampDisplay: "2026-07-26 12:00:00.000", + severity: "Info", + thread: null, + threadDisplay: null, + sourceFile: null, + format: "DnsDebug", + filePath: "C:/Logs/DNSServer/DNSServer_debug.log", + timezoneOffset: null, + sourceIp: "192.168.2.9:54159", + queryName: "contoso.local", + }; +} + +describe("DnsWorkspaceBanner", () => { + beforeEach(() => { + useLogStore.getState().clear(); + useDnsDhcpStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); + useUiStore.setState({ + activeWorkspace: "log", + activeView: "log", + currentPlatform: "windows", + enabledWorkspaces: null, + }); + useLogStore.setState({ + openFilePath: "C:/Logs/DNSServer/DNSServer_debug.log", + formatDetected: "DnsDebug", + parserSelection: parser, + entries: [dnsEntry()], + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("offers a DNS/DHCP handoff and dismisses each path for the session", () => { + render(); + expect( + screen.getByText(/This looks like a DNS debug log/), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open in workspace" })); + expect(useUiStore.getState().activeWorkspace).toBe("dns-dhcp"); + expect(useDnsDhcpStore.getState().sources).toEqual([ + expect.objectContaining({ + path: "C:/Logs/DNSServer/DNSServer_debug.log", + fileName: "DNSServer_debug.log", + format: "DnsDebug", + }), + ]); + + cleanup(); + useUiStore.setState({ activeWorkspace: "log", activeView: "log" }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText(/This looks like a DNS debug log/)).toBeNull(); + + cleanup(); + useLogStore.setState({ openFilePath: "C:/Logs/DNSServer/DNSServer_debug-2.log" }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText(/This looks like a DNS debug log/)).toBeNull(); + + cleanup(); + useLogStore.setState({ openFilePath: "C:/Logs/DNSServer/DNSServer_debug.log" }); + render(); + expect(screen.queryByText(/This looks like a DNS debug log/)).toBeNull(); + }); +}); diff --git a/src/components/log-view/DnsWorkspaceBanner.tsx b/src/components/log-view/DnsWorkspaceBanner.tsx index 40d05b86b..84bac2bd2 100644 --- a/src/components/log-view/DnsWorkspaceBanner.tsx +++ b/src/components/log-view/DnsWorkspaceBanner.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useCallback } from "react"; import { tokens, Button } from "@fluentui/react-components"; import { DismissRegular } from "@fluentui/react-icons"; import { useLogStore } from "../../stores/log-store"; @@ -16,11 +16,15 @@ const DNS_PARSER_KINDS = new Set(["dnsDebug", "dnsAudit", "dhcp"]); export function DnsWorkspaceBanner() { const parserSelection = useLogStore((s) => s.parserSelection); + const openFilePath = useLogStore((s) => s.openFilePath); const activeWorkspace = useUiStore((s) => s.activeWorkspace); - const [dismissed, setDismissed] = useState(false); + const dismissedDnsBannerPaths = useUiStore((s) => s.dismissedDnsBannerPaths); + const dismissDnsBannerPath = useUiStore((s) => s.dismissDnsBannerPath); const parser = parserSelection?.parser; const label = parser ? PARSER_LABELS[parser] : undefined; + const dismissed = + openFilePath !== null && dismissedDnsBannerPaths.includes(openFilePath); const handleOpenInWorkspace = useCallback(() => { const logState = useLogStore.getState(); @@ -61,7 +65,9 @@ export function DnsWorkspaceBanner() { size="small" appearance="subtle" icon={} - onClick={() => setDismissed(true)} + onClick={() => { + if (openFilePath) dismissDnsBannerPath(openFilePath); + }} aria-label="Dismiss" />
diff --git a/src/components/log-view/LogListView.selection.test.tsx b/src/components/log-view/LogListView.selection.test.tsx new file mode 100644 index 000000000..fb10e994a --- /dev/null +++ b/src/components/log-view/LogListView.selection.test.tsx @@ -0,0 +1,165 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LogListView } from "./LogListView"; +import { useFilterStore } from "../../stores/filter-store"; +import { useLogStore } from "../../stores/log-store"; +import { useMarkerStore } from "../../stores/marker-store"; +import { useUiStore } from "../../stores/ui-store"; +import type { LogEntry } from "../../types/log"; + +const scrollToIndex = vi.fn(); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: () => number }) => ({ + getTotalSize: () => count * estimateSize(), + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + size: estimateSize(), + start: index * estimateSize(), + })), + scrollToIndex, + }), +})); + +vi.mock("../../hooks/use-context-menu", () => ({ + useContextMenu: () => ({ showContextMenu: vi.fn() }), +})); + +function makeEntry(id: number, message = `Policy evaluation ${id} completed`): LogEntry { + return { + id, + lineNumber: id * 10, + message, + component: "AppEnforce", + timestamp: Date.parse("2026-07-26T12:00:00Z") + id * 1000, + timestampDisplay: `2026-07-26 12:00:${String(id).padStart(2, "0")}.000`, + severity: id === 3 ? "Error" : "Info", + thread: 1000 + id, + threadDisplay: String(1000 + id), + sourceFile: null, + format: "Ccm", + filePath: "C:/Windows/CCM/Logs/AppEnforce.log", + timezoneOffset: null, + }; +} + +function seedEntries(count = 5) { + useLogStore.setState({ + activeSource: { kind: "file", path: "C:/Windows/CCM/Logs/AppEnforce.log" }, + sourceOpenMode: "single-file", + openFilePath: "C:/Windows/CCM/Logs/AppEnforce.log", + selectedSourceFilePath: "C:/Windows/CCM/Logs/AppEnforce.log", + entries: Array.from({ length: count }, (_, i) => makeEntry(i + 1)), + activeColumns: ["severity", "dateTime", "message"], + correlatedEntries: [], + mergedTabState: null, + selectedId: null, + highlightText: "", + highlightCaseSensitive: false, + isPaused: false, + findMatchIds: [], + pendingScrollTarget: null, + }); +} + +describe("LogListView selection and jump fixtures", () => { + beforeEach(() => { + scrollToIndex.mockReset(); + useLogStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); + useFilterStore.setState(useFilterStore.getInitialState(), true); + useMarkerStore.setState({ + markersByFile: new Map(), + loadingFiles: new Set(), + createdTimestamps: new Map(), + loadMarkers: vi.fn().mockResolvedValue(undefined), + saveMarkers: vi.fn().mockResolvedValue(undefined), + toggleMarker: vi.fn(), + setMarkerCategory: vi.fn(), + }); + useUiStore.setState({ showDetails: true, showInfoPane: true, columnWidths: {}, columnOrder: null }); + seedEntries(); + }); + + afterEach(() => { + cleanup(); + }); + + it("virtualizes rows and selects a clicked entry", () => { + render(); + fireEvent.click(screen.getByText("Policy evaluation 2 completed")); + expect(useLogStore.getState().selectedId).toBe(2); + expect(screen.getByText("Policy evaluation 2 completed").closest("[role='option']")).toHaveAttribute( + "data-selected", + "true", + ); + }); + + it("toggles additive selection with Ctrl/Cmd+click", () => { + render(); + fireEvent.click(screen.getByText("Policy evaluation 1 completed")); + fireEvent.click(screen.getByText("Policy evaluation 3 completed"), { metaKey: true }); + fireEvent.click(screen.getByText("Policy evaluation 4 completed"), { ctrlKey: true }); + expect(screen.getByText("Policy evaluation 4 completed").closest("[role='option']")).toHaveAttribute( + "data-selected", + "true", + ); + expect(screen.getByText("Policy evaluation 1 completed").closest("[role='option']")).toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + }); + + it("expands a Shift-click range and excludes rows outside the range", () => { + render(); + fireEvent.click(screen.getByText("Policy evaluation 1 completed")); + fireEvent.click(screen.getByText("Policy evaluation 3 completed"), { metaKey: true }); + fireEvent.click(screen.getByText("Policy evaluation 5 completed"), { shiftKey: true }); + + expect(screen.getByText("Policy evaluation 5 completed").closest("[role='option']")).toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + expect(screen.getByText("Policy evaluation 3 completed").closest("[role='option']")).toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + expect(screen.getByText("Policy evaluation 2 completed").closest("[role='option']")).not.toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + }); + it.each([{ metaKey: true }, { ctrlKey: true }])( + "selects every displayed row on select-all (%o)", + (modifier) => { + render(); + const list = screen.getByRole("listbox", { name: "Log entries" }); + fireEvent.keyDown(list, { key: "a", ...modifier }); + for (const id of [1, 2, 3, 4, 5]) { + expect(screen.getByText(`Policy evaluation ${id} completed`).closest("[role='option']")).toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + } + }, + ); + + it("consumes a matching pending scroll target and selects the first line at or after the target", () => { + render(); + act(() => { + useLogStore.getState().setPendingScrollTarget({ + filePath: "C:/Windows/CCM/Logs/AppEnforce.log", + lineNumber: 25, + }); + }); + expect(useLogStore.getState().selectedId).toBe(3); + expect(useLogStore.getState().pendingScrollTarget).toBeNull(); + }); + + it("follows live tail to the last row when not paused", () => { + render(); + act(() => { + useLogStore.setState({ + entries: [...useLogStore.getState().entries, makeEntry(6)], + }); + }); + expect(scrollToIndex).toHaveBeenCalledWith(5, { align: "end" }); + }); +}); diff --git a/src/components/log-view/LogRow.stories.test.tsx b/src/components/log-view/LogRow.stories.test.tsx new file mode 100644 index 000000000..2da7cf9ac --- /dev/null +++ b/src/components/log-view/LogRow.stories.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LogRow } from "./LogRow"; +import { getColumnDef } from "../../lib/column-config"; +import { themeSeverityPalettes } from "../../lib/themes/palettes"; +import { DEFAULT_CATEGORIES } from "../../types/markers"; +import type { LogEntry } from "../../types/log"; + +const severityColumn = getColumnDef("severity"); +const messageColumn = getColumnDef("message"); +if (!severityColumn || !messageColumn) { + throw new Error("severity and message columns must exist"); +} +const visibleColumns = [severityColumn, messageColumn]; + +function makeEntry(overrides: Partial = {}): LogEntry { + return { + id: 4, + lineNumber: 40, + message: "Install failed 0x80070005 for app {aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb}", + component: "AppEnforce", + timestamp: Date.parse("2026-07-26T12:00:03Z"), + timestampDisplay: "2026-07-26 12:00:03.000", + severity: "Error", + thread: 1004, + threadDisplay: "1004", + sourceFile: "appexecmgr.cpp", + format: "Ccm", + filePath: "C:/Windows/CCM/Logs/AppEnforce.log", + timezoneOffset: null, + errorCodeSpans: [ + { + start: 15, + end: 25, + codeHex: "0x80070005", + codeDecimal: "2147942405", + description: "Access is denied.", + category: "Win32", + }, + ], + ...overrides, + }; +} + +function renderRow(overrides: Partial[0]> = {}) { + const onClick = vi.fn(); + const onContextMenu = vi.fn(); + const onErrorCodeClick = vi.fn(); + const onToggleMarker = vi.fn(); + const onSetMarkerCategory = vi.fn(); + render( + , + ); + return { onClick, onContextMenu, onErrorCodeClick, onToggleMarker, onSetMarkerCategory }; +} + +describe("LogRow error codes and markers", () => { + it("underlines an HRESULT and stops row selection when the span is activated", () => { + const { onClick, onErrorCodeClick } = renderRow(); + const code = screen.getByRole("button", { name: "0x80070005" }); + expect(code).toHaveStyle({ textDecoration: "underline dotted" }); + fireEvent.click(code); + expect(onErrorCodeClick).toHaveBeenCalledWith( + expect.objectContaining({ codeHex: "0x80070005", description: "Access is denied." }), + ); + expect(onClick).not.toHaveBeenCalled(); + fireEvent.keyDown(code, { key: "Enter" }); + expect(onErrorCodeClick).toHaveBeenCalledTimes(2); + }); + + it("toggles the active marker from the gutter and offers Bug / Investigate / Confirmed / Remove", () => { + const { onClick, onToggleMarker, onSetMarkerCategory } = renderRow({ + marker: { lineId: 4, category: "bug", color: "#ef4444", added: "2026-07-26T12:00:00Z" }, + }); + const gutter = screen.getByTestId("log-row-marker-gutter"); + fireEvent.click(gutter); + expect(onToggleMarker).toHaveBeenCalledWith("C:/Windows/CCM/Logs/AppEnforce.log", 4); + expect(onClick).not.toHaveBeenCalled(); + + fireEvent.contextMenu(gutter); + expect(screen.getByText("Bug")).toBeInTheDocument(); + expect(screen.getByText("Investigate")).toBeInTheDocument(); + expect(screen.getByText("Confirmed")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Investigate")); + expect(onSetMarkerCategory).toHaveBeenCalledWith( + "C:/Windows/CCM/Logs/AppEnforce.log", + 4, + "investigate", + ); + fireEvent.contextMenu(gutter); + fireEvent.click(screen.getByText("Remove Marker")); + expect(onToggleMarker).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/log-view/LogRow.tsx b/src/components/log-view/LogRow.tsx index 69aa62432..01ba559e3 100644 --- a/src/components/log-view/LogRow.tsx +++ b/src/components/log-view/LogRow.tsx @@ -302,7 +302,7 @@ export const LogRow = memo(function LogRow({ } // Multi-select visual: subtle blue background when no marker tint is present - const showMultiSelectHighlight = isMultiSelected && !isSelected && !marker; + const showMultiSelectHighlight = isMultiSelected && !marker; return (
{/* Marker gutter */}
{ + beforeEach(() => { + useLogStore.getState().clear(); + const app = "C:/Windows/CCM/Logs/AppEnforce.log"; + const ci = "C:/Windows/CCM/Logs/CIAgent.log"; + useLogStore.setState({ + entries: [entry(1, app), entry(2, ci), entry(3, app)], + correlationWindowMs: 1000, + autoCorrelate: true, + mergedTabState: { + sourceFilePaths: [app, ci], + colorAssignments: { [app]: "#ef4444", [ci]: "#3b82f6" }, + fileVisibility: { [app]: true, [ci]: true }, + mergedEntries: [entry(1, app), entry(2, ci), entry(3, app)], + cacheKey: buildMergeCacheKey([app, ci], { [app]: 2, [ci]: 1 }), + }, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("toggles file chips, All/None, correlation windows, and Auto", () => { + render(); + expect(screen.getByText("AppEnforce.log")).toBeInTheDocument(); + expect(screen.getByText("CIAgent.log")).toBeInTheDocument(); + expect(screen.getByText("3 merged")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("AppEnforce.log")); + expect(useLogStore.getState().mergedTabState?.fileVisibility["C:/Windows/CCM/Logs/AppEnforce.log"]).toBe( + false, + ); + + fireEvent.click(screen.getByRole("button", { name: "None" })); + expect( + Object.values(useLogStore.getState().mergedTabState?.fileVisibility ?? {}).every((visible) => !visible), + ).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + expect( + Object.values(useLogStore.getState().mergedTabState?.fileVisibility ?? {}).every(Boolean), + ).toBe(true); + + fireEvent.change(screen.getByDisplayValue("1s"), { target: { value: "500" } }); + expect(useLogStore.getState().correlationWindowMs).toBe(500); + + fireEvent.click(screen.getByRole("button", { name: "Auto" })); + expect(useLogStore.getState().autoCorrelate).toBe(false); + }); +}); diff --git a/src/components/log-view/SectionDividerRow.test.tsx b/src/components/log-view/SectionDividerRow.test.tsx new file mode 100644 index 000000000..b1654e067 --- /dev/null +++ b/src/components/log-view/SectionDividerRow.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SectionDividerRow } from "./SectionDividerRow"; +import type { LogEntry } from "../../types/log"; + +function sectionEntry(): LogEntry { + return { + id: 12, + lineNumber: 120, + message: "Section: AppEnforce", + component: null, + timestamp: Date.parse("2026-07-26T12:00:00Z"), + timestampDisplay: "2026-07-26 12:00:00.000", + severity: "Info", + thread: null, + threadDisplay: null, + sourceFile: null, + format: "Ccm", + filePath: "C:/Windows/CCM/Logs/AppEnforce.log", + timezoneOffset: null, + entryKind: "Section", + sectionName: "AppEnforce", + sectionColor: "#3b82f6", + }; +} + +describe("SectionDividerRow", () => { + it("renders a section banner and selects the row on click", () => { + const onClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("option")); + expect(onClick).toHaveBeenCalledWith(12); + expect(screen.getByText("Section: AppEnforce")).toBeInTheDocument(); + }); + + it("shows the iteration caption on Iteration banners", () => { + render( + , + ); + expect(screen.getByText("Pass 2")).toBeInTheDocument(); + }); +}); diff --git a/src/components/registry-view/RegistryViewer.test.tsx b/src/components/registry-view/RegistryViewer.test.tsx new file mode 100644 index 000000000..0e66e266a --- /dev/null +++ b/src/components/registry-view/RegistryViewer.test.tsx @@ -0,0 +1,71 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RegistryViewer } from "./RegistryViewer"; +import { useRegistryStore } from "../../stores/registry-store"; +import { useLogStore } from "../../stores/log-store"; +import type { RegistryParseResult } from "../../types/registry"; + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 26, + size: 26, + })), + getTotalSize: () => count * 26, + scrollToIndex: vi.fn(), + }), +})); + +const fixture: RegistryParseResult = { + filePath: "C:/Windows/Temp/secureboot.reg", + fileSize: 2048, + totalKeys: 2, + totalValues: 2, + parseErrors: 0, + keys: [ + { + path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot", + lineNumber: 3, + isDelete: false, + values: [ + { name: "AvailableUpdates", kind: "dword", data: "0x2", lineNumber: 4 }, + ], + }, + { + path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State", + lineNumber: 6, + isDelete: false, + values: [ + { name: "UEFISecureBootEnabled", kind: "dword", data: "0x1", lineNumber: 7 }, + ], + }, + ], +}; + +describe("RegistryViewer", () => { + beforeEach(() => { + useRegistryStore.getState().clear(); + useLogStore.getState().clear(); + useLogStore.setState({ openFilePath: fixture.filePath }); + useRegistryStore.getState().setRegistryData(fixture); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows key/value counts, tree selection, and Name/Type/Data values", () => { + render(); + expect(screen.getByText("Registry Keys")).toBeInTheDocument(); + expect(screen.getByText("2 keys, 2 values")).toBeInTheDocument(); + fireEvent.click(screen.getByText("SecureBoot")); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Type")).toBeInTheDocument(); + expect(screen.getByText("Data")).toBeInTheDocument(); + expect(screen.getByText("AvailableUpdates")).toBeInTheDocument(); + expect(screen.getByText("0x2")).toBeInTheDocument(); + }); +}); diff --git a/src/components/timeline/TimelineWorkspace.tsx b/src/components/timeline/TimelineWorkspace.tsx index 8d5c54ffd..3acd8e5a6 100644 --- a/src/components/timeline/TimelineWorkspace.tsx +++ b/src/components/timeline/TimelineWorkspace.tsx @@ -10,12 +10,12 @@ import { TimelineRuler } from "./TimelineRuler"; import { BrushOverlay } from "./BrushOverlay"; import { LogListView } from "../log-view/LogListView"; import { timelineLogListDataSource } from "./log-list-adapter"; -import { buildTimelineFromSources } from "./hooks/useTimelineBundle"; const LANE_HEIGHT = 22; export function TimelineWorkspace() { const bundle = useTimelineStore((s) => s.bundle); + const loadError = useTimelineStore((s) => s.loadError); const laneVisibility = useTimelineStore((s) => s.laneVisibility); const soloSourceIdx = useTimelineStore((s) => s.soloSourceIdx); const [hover, setHover] = useState(null); @@ -52,15 +52,16 @@ export function TimelineWorkspace() { .map((f) => (f as File & { path?: string }).path) .filter((p): p is string => typeof p === "string" && p.length > 0); if (paths.length === 0) return; - const existing = - useTimelineStore.getState().bundle?.sources.map((s) => s.path) ?? []; - const merged = Array.from(new Set([...existing, ...paths])).map( - (path) => ({ path }), - ); try { - await buildTimelineFromSources(merged); - } catch (err) { - console.error("[timeline] failed to add sources to timeline", err); + const { openTimelineFiles } = await import( + "../../workspaces/timeline/open-timeline-source" + ); + await openTimelineFiles(paths); + } catch (error) { + console.error("[timeline] failed to add sources to timeline", error); + useTimelineStore + .getState() + .setLoadError(error instanceof Error ? error.message : String(error)); } }; @@ -78,6 +79,18 @@ export function TimelineWorkspace() { borderRadius: 8, }} > + {loadError && ( +
+ {loadError} +
+ )}
Drop log files here
@@ -100,12 +113,32 @@ export function TimelineWorkspace() { onDrop={handleDrop} onDragOver={handleDragOver} style={{ + position: "relative", display: "grid", gridTemplateColumns: "1fr 340px", gridTemplateRows: "auto auto auto 1fr", height: "100%", }} > + {loadError && ( +
+ {loadError} +
+ )}
diff --git a/src/components/timeline/hooks/useTimelineBundle.test.ts b/src/components/timeline/hooks/useTimelineBundle.test.ts new file mode 100644 index 000000000..e47686065 --- /dev/null +++ b/src/components/timeline/hooks/useTimelineBundle.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildTimeline } from "../../../lib/commands"; +import { deferred } from "../../../test-utils/deferred"; +import { useTimelineStore } from "../../../stores/timeline-store"; +import type { TimelineBundle } from "../../../types/timeline"; +import { buildTimelineFromSources } from "./useTimelineBundle"; + +vi.mock("../../../lib/commands", () => ({ + buildTimeline: vi.fn(), +})); + +function timelineBundle(): TimelineBundle { + return { + id: "stale-build", + sources: [], + timeRangeMs: [0, 0], + totalEntries: 0, + incidents: [], + deniedGuids: [], + errors: [], + tunables: { + overlapWindowMs: 5_000, + minSourceCount: 2, + maxIncidentSpanMs: 60_000, + enabledSignalKinds: ["errorSeverity"], + }, + }; +} + +describe("buildTimelineFromSources", () => { + beforeEach(() => { + vi.clearAllMocks(); + useTimelineStore.getState().reset(); + }); + + it("does not restore a build completed after New Timeline Empty", async () => { + const pendingBuild = deferred(); + vi.mocked(buildTimeline).mockReturnValueOnce(pendingBuild.promise); + + const build = buildTimelineFromSources([{ path: "/tmp/stale.log" }]); + await vi.waitFor(() => { + expect(buildTimeline).toHaveBeenCalledWith([{ path: "/tmp/stale.log" }]); + }); + + useTimelineStore.getState().setBundle(null); + pendingBuild.resolve(timelineBundle()); + + await expect(build).resolves.toEqual(timelineBundle()); + expect(useTimelineStore.getState().bundle).toBeNull(); + }); +}); diff --git a/src/components/timeline/hooks/useTimelineBundle.ts b/src/components/timeline/hooks/useTimelineBundle.ts index 8203e61f1..958e8257a 100644 --- a/src/components/timeline/hooks/useTimelineBundle.ts +++ b/src/components/timeline/hooks/useTimelineBundle.ts @@ -1,13 +1,16 @@ import { useCallback, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; +import { buildTimeline } from "../../../lib/commands"; import { useTimelineStore } from "../../../stores/timeline-store"; import type { TimelineBundle } from "../../../types/timeline"; export async function buildTimelineFromSources( sources: { path: string; displayName?: string }[], ): Promise { - const bundle = await invoke("build_timeline_cmd", { sources }); - useTimelineStore.getState().setBundle(bundle); + const timelineGeneration = useTimelineStore.getState().timelineGeneration; + const bundle = await buildTimeline(sources); + if (useTimelineStore.getState().timelineGeneration === timelineGeneration) { + useTimelineStore.getState().setBundle(bundle); + } return bundle; } diff --git a/src/hooks/use-app-actions.path-open.test.tsx b/src/hooks/use-app-actions.path-open.test.tsx new file mode 100644 index 000000000..40cc8c109 --- /dev/null +++ b/src/hooks/use-app-actions.path-open.test.tsx @@ -0,0 +1,91 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUiStore } from "../stores/ui-store"; + +const analyzeDsregcmdPath = vi.hoisted(() => vi.fn()); +const recordRecentPath = vi.hoisted(() => vi.fn()); +const inspectPathKind = vi.hoisted(() => vi.fn()); +const openEventLogSource = vi.hoisted(() => vi.fn()); +const setLoadError = vi.hoisted(() => vi.fn()); + +vi.mock("../lib/dsregcmd-source", () => ({ + analyzeDsregcmdPath, + analyzeDsregcmdSource: vi.fn(), + refreshCurrentDsregcmdSource: vi.fn(), +})); + +vi.mock("../lib/recent-entries", () => ({ + recordRecentPath, + recordRecentSource: vi.fn(), +})); + +vi.mock("../lib/commands", () => ({ + inspectPathKind, +})); + +vi.mock("../workspaces/event-log/open-event-log-source", () => ({ + openEventLogSource, +})); +vi.mock("../workspaces/event-log/evtx-store", () => ({ + useEvtxStore: { + getState: () => ({ setLoadError }), + }, +})); + +import { useAppActions } from "./use-app-actions"; + +describe("openPathForActiveWorkspace dsregcmd", () => { + beforeEach(() => { + vi.clearAllMocks(); + analyzeDsregcmdPath.mockResolvedValue({}); + recordRecentPath.mockResolvedValue(undefined); + inspectPathKind.mockRejectedValue(new Error("inspect failed")); + useUiStore.setState({ + activeWorkspace: "dsregcmd", + activeView: "dsregcmd", + currentPlatform: "windows", + enabledWorkspaces: null, + }); + }); + + it("retries an uninspectable drop as a folder and records Recent", async () => { + const { result } = renderHook(() => useAppActions()); + await result.current.openPathForActiveWorkspace("C:/Evidence/dsregcmd"); + + expect(analyzeDsregcmdPath).toHaveBeenCalledWith("C:/Evidence/dsregcmd", { + fallbackToFolder: true, + }); + expect(recordRecentPath).toHaveBeenCalledWith("C:/Evidence/dsregcmd", "dsregcmd"); + }); +}); + +describe("openPathForActiveWorkspace event-log", () => { + beforeEach(() => { + vi.clearAllMocks(); + inspectPathKind.mockRejectedValue(new Error("inspect failed")); + openEventLogSource + .mockRejectedValueOnce(new Error("not a file")) + .mockResolvedValueOnce(undefined); + useUiStore.setState({ + activeWorkspace: "event-log", + activeView: "event-log", + currentPlatform: "windows", + enabledWorkspaces: null, + }); + }); + + it("retries an uninspectable drop as a folder after a file open fails", async () => { + const { result } = renderHook(() => useAppActions()); + await result.current.openPathForActiveWorkspace("C:/Windows/System32/winevt/Logs"); + + expect(openEventLogSource).toHaveBeenNthCalledWith(1, { + kind: "file", + path: "C:/Windows/System32/winevt/Logs", + }); + expect(openEventLogSource).toHaveBeenNthCalledWith(2, { + kind: "folder", + path: "C:/Windows/System32/winevt/Logs", + }); + expect(setLoadError).toHaveBeenCalledWith("not a file"); + }); +}); diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index 78ff072e3..d28f86965 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -372,8 +372,8 @@ export function useAppActions(): AppActionHandlers { useFilterStore.getState().clearFilter(); try { - await loadLogSource(source); - return true; + const result = await loadLogSource(source); + return result !== null; } catch (error) { console.error("[app-actions] failed to load source", { source, @@ -414,16 +414,38 @@ export function useAppActions(): AppActionHandlers { return; } - if (isIntuneWorkspace(activeWorkspace)) { + const workspaceDefinition = getWorkspace(activeWorkspace); + if (workspaceDefinition.onOpenPath) { + await workspaceDefinition.onOpenPath(path); + return; + } + if (workspaceDefinition.onOpenSource) { const pathKind = await inferPathKind(path); - const source: LogSource = - pathKind === "folder" - ? { kind: "folder", path } - : { kind: "file", path }; - await getWorkspace(activeWorkspace).onOpenSource!( - source, - "drag-drop.path-open", - ); + if (pathKind === "folder") { + await workspaceDefinition.onOpenSource( + { kind: "folder", path }, + "drag-drop.path-open", + ); + return; + } + if (pathKind === "file") { + await workspaceDefinition.onOpenSource( + { kind: "file", path }, + "drag-drop.path-open", + ); + return; + } + try { + await workspaceDefinition.onOpenSource( + { kind: "file", path }, + "drag-drop.path-open", + ); + } catch { + await workspaceDefinition.onOpenSource( + { kind: "folder", path }, + "drag-drop.path-open", + ); + } return; } @@ -437,10 +459,10 @@ export function useAppActions(): AppActionHandlers { useUiStore.getState().ensureLogViewVisible("drag-drop.path-open"); useFilterStore.getState().clearFilter(); - await loadPathAsLogSource(path, { + const result = await loadPathAsLogSource(path, { fallbackToFolder: true, }); - if (isRecordableWorkspace(activeWorkspace)) { + if (result !== null && isRecordableWorkspace(activeWorkspace)) { void recordRecentPath(path, activeWorkspace); } }, diff --git a/src/hooks/use-app-menu.test.tsx b/src/hooks/use-app-menu.test.tsx index 65f7d7669..24677c8b1 100644 --- a/src/hooks/use-app-menu.test.tsx +++ b/src/hooks/use-app-menu.test.tsx @@ -18,6 +18,14 @@ const eventMocks = vi.hoisted(() => { return { state, listen, unlisten }; }); +const timelineMocks = vi.hoisted(() => ({ + replaceTimelineSource: vi.fn(async () => undefined), +})); + +const dialogMocks = vi.hoisted(() => ({ + open: vi.fn(async () => undefined as string | undefined), +})); + const actionMocks = vi.hoisted(() => ({ current: { commandState: { @@ -89,6 +97,12 @@ vi.mock("../lib/recent-entries", () => ({ clearRecentEntries: recentMocks.clearRecentEntries, })); +vi.mock("../workspaces/timeline/open-timeline-source", () => timelineMocks); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: dialogMocks.open, +})); + interface TestMenuPayload { version: number; menu_id: string; @@ -162,6 +176,10 @@ describe("useAppMenu", () => { vi.mocked(invoke).mockReset().mockResolvedValue(undefined); eventMocks.state.callback = null; actionMocks.current.commandState = { ...initialCommandState }; + timelineMocks.replaceTimelineSource + .mockReset() + .mockResolvedValue(undefined); + dialogMocks.open.mockReset().mockResolvedValue(undefined); useUiStore.setState({ activeWorkspace: "log", activeView: "log", @@ -348,6 +366,18 @@ describe("useAppMenu", () => { expect.any(Object), ); }); + it("opens a replacement timeline for native New Timeline folder opens", async () => { + dialogMocks.open.mockResolvedValueOnce("C:/Evidence/NewTimeline"); + + renderHook(() => useAppMenu()); + await waitFor(() => expect(eventMocks.state.callback).not.toBeNull()); + await emitMenuAction({ action: "timeline_new_from_folder" }); + + expect(timelineMocks.replaceTimelineSource).toHaveBeenCalledWith({ + kind: "folder", + path: "C:/Evidence/NewTimeline", + }); + }); it("opens a recent entry in its recorded workspace", async () => { renderHook(() => useAppMenu()); @@ -430,6 +460,27 @@ describe("useAppMenu", () => { expect(recentMocks.clearRecentEntries).toHaveBeenCalled(); }); + + it("toggles Always on Top and invokes the native pin", async () => { + useUiStore.setState({ alwaysOnTop: false }); + renderHook(() => useAppMenu()); + await waitFor(() => expect(eventMocks.state.callback).not.toBeNull()); + + await emitMenuAction({ action: "toggle_always_on_top" }); + + expect(useUiStore.getState().alwaysOnTop).toBe(true); + expect(invoke).toHaveBeenCalledWith("set_always_on_top", { enabled: true }); + }); + + it("opens the Collect Diagnostics dialog from the native menu", async () => { + useUiStore.setState({ showCollectDiagnosticsDialog: false }); + renderHook(() => useAppMenu()); + await waitFor(() => expect(eventMocks.state.callback).not.toBeNull()); + + await emitMenuAction({ action: "collect_diagnostics" }); + + expect(useUiStore.getState().showCollectDiagnosticsDialog).toBe(true); + }); }); describe("useKeyboard native menu parity", () => { @@ -444,7 +495,12 @@ describe("useKeyboard native menu parity", () => { showAboutDialog: false, showSettingsDialog: false, showEvidenceBundleDialog: false, + showGuidRegistryDialog: false, + showMergeTabsDialog: false, + showDiffConfigDialog: false, showFileAssociationPrompt: false, + showCollectDiagnosticsDialog: false, + collectionResult: null, }); }); @@ -497,6 +553,72 @@ describe("useKeyboard native menu parity", () => { useUiStore.setState({ elevationPrompt: null }); }); + it("suppresses shortcuts for collection overlays and DOM modal surfaces", () => { + useUiStore.setState({ + currentPlatform: "windows", + showCollectDiagnosticsDialog: true, + }); + renderHook(() => useKeyboard()); + + expect( + fireEvent.keyDown(window, { key: "h", ctrlKey: true }), + ).toBe(false); + expect(actionMocks.current.toggleDetailsPane).not.toHaveBeenCalled(); + + cleanup(); + useUiStore.setState({ + showCollectDiagnosticsDialog: false, + collectionResult: { + bundlePath: "C:/Evidence", + bundleId: "bundle-fixture", + artifactCounts: { collected: 1, missing: 0, failed: 0, total: 1 }, + durationMs: 1, + gaps: [], + }, + }); + renderHook(() => useKeyboard()); + expect( + fireEvent.keyDown(window, { key: "h", ctrlKey: true }), + ).toBe(false); + + cleanup(); + useUiStore.setState({ collectionResult: null }); + const modal = document.createElement("div"); + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + document.body.appendChild(modal); + const input = document.createElement("input"); + modal.appendChild(input); + renderHook(() => useKeyboard()); + expect( + fireEvent.keyDown(window, { key: "h", ctrlKey: true }), + ).toBe(false); + input.focus(); + expect( + fireEvent.keyDown(input, { key: "v", ctrlKey: true }), + ).toBe(true); + expect( + fireEvent.keyDown(input, { key: "o", ctrlKey: true }), + ).toBe(false); + modal.remove(); + }); + it("allows AltGr text entry in modal inputs", () => { + useUiStore.setState({ + currentPlatform: "windows", + showCollectDiagnosticsDialog: true, + }); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + renderHook(() => useKeyboard()); + + expect( + fireEvent.keyDown(input, { key: "@", ctrlKey: true, altKey: true }), + ).toBe(true); + expect(actionMocks.current.toggleDetailsPane).not.toHaveBeenCalled(); + + input.remove(); + }); it("restarts a non-log workspace without dragging a stale source along", async () => { useUiStore.setState({ activeWorkspace: "esp-diagnostics" }); // activeSource survives a workspace switch, so it is still set here even diff --git a/src/hooks/use-app-menu.ts b/src/hooks/use-app-menu.ts index 3d30058ed..9317eb0d0 100644 --- a/src/hooks/use-app-menu.ts +++ b/src/hooks/use-app-menu.ts @@ -363,33 +363,25 @@ export function useAppMenu() { if (!folder || Array.isArray(folder)) return; const folderPath = folder as string; try { - const { listLogFolder } = await import("../lib/commands"); - const listing = await listLogFolder(folderPath); - const childPaths = listing.entries - .filter((entry) => !entry.isDir) - .map((entry) => entry.path); - const sources: { path: string }[] = childPaths.map((path) => ({ path })); - // If the folder contains IME logs, add the folder itself as a source - // so the backend can detect and apply IME-specialised parsing. - const hasIme = childPaths.some((p) => { - const lower = p.toLowerCase(); - return ( - lower.endsWith("agentexecutor.log") || - lower.endsWith("intunemanagementextension.log") - ); - }); - if (hasIme) sources.push({ path: folderPath }); - if (sources.length === 0) return; - const { buildTimelineFromSources } = await import( - "../components/timeline/hooks/useTimelineBundle" + const { replaceTimelineSource } = await import( + "../workspaces/timeline/open-timeline-source" + ); + useUiStore.getState().ensureWorkspaceVisible( + "timeline", + "native-menu.timeline-new-from-folder", ); - await buildTimelineFromSources(sources); - useUiStore.getState().ensureWorkspaceVisible("timeline", "native-menu.timeline-new-from-folder"); + await replaceTimelineSource({ kind: "folder", path: folderPath }); } catch (error) { console.error("[app-menu] failed to build timeline from folder", { folderPath, error, }); + const { useTimelineStore } = await import( + "../stores/timeline-store" + ); + useTimelineStore.getState().setLoadError( + error instanceof Error ? error.message : String(error), + ); } return; } diff --git a/src/hooks/use-context-menu.test.ts b/src/hooks/use-context-menu.test.ts new file mode 100644 index 000000000..63a86f9aa --- /dev/null +++ b/src/hooks/use-context-menu.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { LogEntry } from "../types/log"; + +const menuItemNew = vi.hoisted( + () => vi.fn(async (opts: { id: string; text: string; action?: () => void }) => opts), +); +const predefinedNew = vi.hoisted(() => vi.fn(async (opts: { item: string }) => opts)); +const menuNew = vi.hoisted(() => + vi.fn(async ({ items }: { items: unknown[] }) => ({ + items, + popup: vi.fn(async () => undefined), + })), +); + +vi.mock("@tauri-apps/api/menu", () => ({ + MenuItem: { new: menuItemNew }, + PredefinedMenuItem: { new: predefinedNew }, + Menu: { new: menuNew }, +})); + +const writeText = vi.hoisted(() => vi.fn(async (_text: string) => undefined)); + +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + writeText, +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { useContextMenu } from "./use-context-menu"; +import { renderHook } from "@testing-library/react"; +import { useFilterStore } from "../stores/filter-store"; +import { useMarkerStore } from "../stores/marker-store"; + +function entry(overrides: Partial = {}): LogEntry { + return { + id: 4, + lineNumber: 40, + message: "Install failed 0x80070005 for app Contoso VPN", + component: "AppEnforce", + timestamp: Date.parse("2026-07-26T12:00:03Z"), + timestampDisplay: "2026-07-26 12:00:03.000", + severity: "Error", + thread: 1004, + threadDisplay: "1004", + sourceFile: "appexecmgr.cpp", + format: "Ccm", + filePath: "C:/Windows/CCM/Logs/AppEnforce.log", + timezoneOffset: null, + errorCodeSpans: [ + { + start: 15, + end: 25, + codeHex: "0x80070005", + codeDecimal: "2147942405", + description: "Access is denied.", + category: "Win32", + }, + ], + ...overrides, + }; +} + +describe("useContextMenu", () => { + beforeEach(() => { + menuItemNew.mockClear(); + predefinedNew.mockClear(); + menuNew.mockClear(); + writeText.mockClear(); + useFilterStore.getState().clearFilter(); + useMarkerStore.setState({ + markersByFile: new Map(), + categories: [ + { id: "bug", label: "Bug", color: "#ef4444" }, + { id: "investigate", label: "Investigate", color: "#60a5fa" }, + { id: "confirmed", label: "Confirmed", color: "#4ade80" }, + ], + }); + }); + + it("builds copy, filter, jump, marker, error lookup, and reveal items", async () => { + const { result } = renderHook(() => useContextMenu()); + await result.current.showContextMenu(entry(), { + preventDefault: vi.fn(), + } as unknown as React.MouseEvent); + + const labels = menuItemNew.mock.calls.map((call) => call[0].text); + expect(labels).toEqual( + expect.arrayContaining([ + "Copy Line", + "Copy Message", + "Copy Timestamp", + "Jump to Line…", + "Mark as Bug", + "Mark as Investigate", + "Mark as Confirmed", + "Error Lookup: 0x80070005", + "Open Source File", + ]), + ); + expect(labels.some((label) => label.startsWith("Include:"))).toBe(true); + expect(labels.some((label) => label.startsWith("Exclude:"))).toBe(true); + expect(menuNew).toHaveBeenCalled(); + }); + + it("runs copy and include-filter actions for the selected entry", async () => { + const selectedEntry = entry(); + const { result } = renderHook(() => useContextMenu()); + await result.current.showContextMenu(selectedEntry, { + preventDefault: vi.fn(), + } as unknown as React.MouseEvent); + + const copyMessage = menuItemNew.mock.calls.find( + ([opts]) => opts.id === "copy-message", + )?.[0]; + const includeFilter = menuItemNew.mock.calls.find( + ([opts]) => opts.id === "include-filter", + )?.[0]; + + expect(copyMessage?.action).toBeTypeOf("function"); + expect(includeFilter?.action).toBeTypeOf("function"); + + copyMessage?.action?.(); + expect(writeText).toHaveBeenCalledWith(selectedEntry.message); + + includeFilter?.action?.(); + expect(useFilterStore.getState().clauses).toEqual([ + { field: "Message", value: selectedEntry.message, op: "Contains" }, + ]); + }); +}); diff --git a/src/hooks/use-drag-drop.test.tsx b/src/hooks/use-drag-drop.test.tsx new file mode 100644 index 000000000..0bc6c08f3 --- /dev/null +++ b/src/hooks/use-drag-drop.test.tsx @@ -0,0 +1,167 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUiStore } from "../stores/ui-store"; +import { useDragDrop } from "./use-drag-drop"; + +const { + openPathForActiveWorkspaceMock, + loadFilesAsLogSourceMock, + onDragDropEventMock, +} = vi.hoisted(() => ({ + openPathForActiveWorkspaceMock: vi.fn(), + loadFilesAsLogSourceMock: vi.fn(), + onDragDropEventMock: vi.fn(), +})); + +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + onDragDropEvent: onDragDropEventMock, + }), +})); + +vi.mock("./use-app-actions", () => ({ + useAppActions: () => ({ + openPathForActiveWorkspace: openPathForActiveWorkspaceMock, + }), +})); + +vi.mock("../lib/log-source", () => ({ + loadFilesAsLogSource: loadFilesAsLogSourceMock, +})); + +// Static imports are safe: app actions and log-source are mocked above. + +type DropHandler = (event: { + payload: { type: string; paths: string[] }; +}) => Promise | void; + +function latestHandler(): DropHandler { + const calls = onDragDropEventMock.mock.calls; + const handler = calls[calls.length - 1]?.[0] as DropHandler | undefined; + if (!handler) { + throw new Error("onDragDropEvent was not registered"); + } + return handler; +} + +describe("useDragDrop", () => { + beforeEach(() => { + vi.clearAllMocks(); + onDragDropEventMock.mockResolvedValue(() => undefined); + openPathForActiveWorkspaceMock.mockResolvedValue(undefined); + loadFilesAsLogSourceMock.mockResolvedValue(true); + useUiStore.setState({ + activeWorkspace: "log", + activeView: "log", + }); + }); + + it("opens a single dropped path on the active workspace", async () => { + renderHook(() => useDragDrop()); + + await latestHandler()({ + payload: { type: "drop", paths: ["/tmp/AppSetup.log"] }, + }); + + expect(openPathForActiveWorkspaceMock).toHaveBeenCalledWith("/tmp/AppSetup.log"); + expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); + }); + + it("loads multiple dropped paths as a log source in the log workspace", async () => { + renderHook(() => useDragDrop()); + + await latestHandler()({ + payload: { + type: "drop", + paths: ["/tmp/a.log", "/tmp/b.log"], + }, + }); + + expect(loadFilesAsLogSourceMock).toHaveBeenCalledWith([ + "/tmp/a.log", + "/tmp/b.log", + ]); + expect(openPathForActiveWorkspaceMock).not.toHaveBeenCalled(); + }); + + it("opens only the first path for multi-file drops outside the log workspace", async () => { + useUiStore.setState({ + activeWorkspace: "intune", + activeView: "intune", + }); + renderHook(() => useDragDrop()); + + await latestHandler()({ + payload: { + type: "drop", + paths: ["/tmp/ime.log", "/tmp/agentexecutor.log"], + }, + }); + + expect(openPathForActiveWorkspaceMock).toHaveBeenCalledWith("/tmp/ime.log"); + expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); + }); + + it("routes every timeline drop through the active workspace opener", async () => { + useUiStore.setState({ + activeWorkspace: "timeline", + activeView: "timeline", + }); + renderHook(() => useDragDrop()); + + await latestHandler()({ + payload: { + type: "drop", + paths: ["/tmp/ime.log", "/tmp/empty-folder"], + }, + }); + + expect(openPathForActiveWorkspaceMock).toHaveBeenNthCalledWith( + 1, + "/tmp/ime.log", + ); + expect(openPathForActiveWorkspaceMock).toHaveBeenNthCalledWith( + 2, + "/tmp/empty-folder", + ); + expect(openPathForActiveWorkspaceMock).toHaveBeenCalledTimes(2); + expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); + }); + it("continues opening timeline drops after one path fails", async () => { + useUiStore.setState({ + activeWorkspace: "timeline", + activeView: "timeline", + }); + openPathForActiveWorkspaceMock + .mockRejectedValueOnce(new Error("unreadable")) + .mockResolvedValue(undefined); + renderHook(() => useDragDrop()); + + await latestHandler()({ + payload: { + type: "drop", + paths: ["/tmp/unreadable.log", "/tmp/readable.log"], + }, + }); + + expect(openPathForActiveWorkspaceMock).toHaveBeenNthCalledWith( + 1, + "/tmp/unreadable.log", + ); + expect(openPathForActiveWorkspaceMock).toHaveBeenNthCalledWith( + 2, + "/tmp/readable.log", + ); + }); + + it("ignores non-drop drag events and empty path lists", async () => { + renderHook(() => useDragDrop()); + const handler = latestHandler(); + + await handler({ payload: { type: "enter", paths: ["/tmp/a.log"] } }); + await handler({ payload: { type: "drop", paths: [] } }); + + expect(openPathForActiveWorkspaceMock).not.toHaveBeenCalled(); + expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-drag-drop.ts b/src/hooks/use-drag-drop.ts index 64fc872cd..6d23f5f7a 100644 --- a/src/hooks/use-drag-drop.ts +++ b/src/hooks/use-drag-drop.ts @@ -29,17 +29,16 @@ export function useDragDrop() { const activeWorkspace = useUiStore.getState().activeWorkspace; if (activeWorkspace === "timeline") { - const { useTimelineStore } = await import("../stores/timeline-store"); - const { buildTimelineFromSources } = await import( - "../components/timeline/hooks/useTimelineBundle" - ); - const existing = - useTimelineStore.getState().bundle?.sources.map((s) => s.path) ?? []; - const merged = Array.from(new Set([...existing, ...paths])).map( - (path) => ({ path }), - ); - if (merged.length === 0) return; - await buildTimelineFromSources(merged); + for (const path of paths) { + try { + await openPathForActiveWorkspace(path); + } catch (error) { + console.error("[drag-drop] failed to open dropped path", { + path, + error, + }); + } + } return; } diff --git a/src/hooks/use-file-association.test.tsx b/src/hooks/use-file-association.test.tsx index 8761aa162..9327cdf7f 100644 --- a/src/hooks/use-file-association.test.tsx +++ b/src/hooks/use-file-association.test.tsx @@ -44,7 +44,7 @@ vi.mock("../workspaces/registry", async (importOriginal) => { ...actual, getWorkspace: (id: WorkspaceId) => { const workspace = actual.getWorkspace(id); - return id === "intune" || id === "esp-diagnostics" + return id === "intune" || id === "esp-diagnostics" || id === "event-log" ? { ...workspace, onOpenSource: workspaceOpenSourceMock } : workspace; }, @@ -91,7 +91,7 @@ describe("useFileAssociation startup routing", () => { selectedFilePath: null, parseResult: null, })); - loadFilesAsLogSourceMock.mockResolvedValue(undefined); + loadFilesAsLogSourceMock.mockResolvedValue(true); }); it("opens ESP Diagnostics when the elevated launch requests its workspace", async () => { @@ -277,8 +277,7 @@ describe("useFileAssociation startup routing", () => { } }); - it("does not load hidden generic log state for a workspace without a source handler", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + it("restores an Event Log source through the workspace handler", async () => { useUiStore.setState({ currentPlatform: "windows" }); getInitialElevationRestoreMock.mockResolvedValue( ticket({ @@ -289,22 +288,18 @@ describe("useFileAssociation startup routing", () => { renderHook(() => useFileAssociation()); - try { - await waitFor(() => - expect(warn).toHaveBeenCalledWith( - "[elevation] requested workspace cannot restore sources; source restore skipped", - { workspace: "event-log" }, - ), - ); - expect(useUiStore.getState().activeView).toBe("event-log"); - expect(workspaceOpenSourceMock).not.toHaveBeenCalled(); - expect(loadPathAsLogSourceMock).not.toHaveBeenCalled(); - expect(loadLogSourceMock).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - } + await waitFor(() => + expect(workspaceOpenSourceMock).toHaveBeenCalledWith( + { kind: "file", path: "C:\\Windows\\protected.evtx" }, + "startup.elevation-restore", + ), + ); + expect(useUiStore.getState().activeView).toBe("event-log"); + expect(loadPathAsLogSourceMock).not.toHaveBeenCalled(); + expect(loadLogSourceMock).not.toHaveBeenCalled(); }); + it("reopens the exact typed folder a ticket names", async () => { getInitialElevationRestoreMock.mockResolvedValue( ticket({ target: { kind: "folder", path: "C:\\Windows\\Logs" } }), diff --git a/src/hooks/use-keyboard.ts b/src/hooks/use-keyboard.ts index 2650f72ab..a8e63cd90 100644 --- a/src/hooks/use-keyboard.ts +++ b/src/hooks/use-keyboard.ts @@ -19,6 +19,17 @@ function isTypingTarget(target: EventTarget | null): boolean { ); } +function isNativeTextEditingShortcut( + event: KeyboardEvent, + isInput: boolean, +): boolean { + if (!isInput || !(event.ctrlKey || event.metaKey)) { + return false; + } + + return /^[acvxyz]$/i.test(event.key); +} + function isLogListFocused(): boolean { const active = document.activeElement; @@ -28,6 +39,9 @@ function isLogListFocused(): boolean { return active.closest("[data-log-list='true']") !== null; } +function hasModalSurface(): boolean { + return document.querySelector('[role="dialog"][aria-modal="true"]') !== null; +} function getDisplayEntryIds(): number[] { const logState = useLogStore.getState(); @@ -106,24 +120,40 @@ function navigateSelection(key: string): boolean { */ export function useKeyboard() { const showFindBarOpen = useUiStore((state) => state.showFindBar); - // A boolean, not the prompt itself: only openness matters here, and selecting - // the object would re-register the listener on every identity change. const elevationPromptOpen = useUiStore( (state) => state.elevationPrompt !== null, ); const showFilterDialogOpen = useUiStore((state) => state.showFilterDialog); const showErrorLookupDialogOpen = useUiStore( - (state) => state.showErrorLookupDialog + (state) => state.showErrorLookupDialog, ); const showAboutDialogOpen = useUiStore((state) => state.showAboutDialog); const showSettingsDialogOpen = useUiStore( - (state) => state.showSettingsDialog + (state) => state.showSettingsDialog, ); const showEvidenceBundleDialogOpen = useUiStore( - (state) => state.showEvidenceBundleDialog + (state) => state.showEvidenceBundleDialog, + ); + const showGuidRegistryDialogOpen = useUiStore( + (state) => state.showGuidRegistryDialog, + ); + const showMergeTabsDialogOpen = useUiStore( + (state) => state.showMergeTabsDialog, + ); + const showDiffConfigDialogOpen = useUiStore( + (state) => state.showDiffConfigDialog, ); const showFileAssociationPromptOpen = useUiStore( - (state) => state.showFileAssociationPrompt + (state) => state.showFileAssociationPrompt, + ); + const showCollectDiagnosticsDialogOpen = useUiStore( + (state) => state.showCollectDiagnosticsDialog, + ); + const collectionResultOpen = useUiStore( + (state) => state.collectionResult !== null, + ); + const showUpdateDialogOpen = useUiStore( + (state) => state.showUpdateDialog, ); const { commandState, @@ -145,31 +175,37 @@ export function useKeyboard() { useEffect(() => { const handleKeyDown = async (event: KeyboardEvent) => { - // The elevation prompt is a true modal: it traps focus and dims the app - // behind it. Unlike the find bar or the filter dialog, which deliberately - // leave the surrounding shortcuts live, nothing here may act on content - // the user cannot see or reach. - if (elevationPromptOpen) { - // Cancel the WebView's own Ctrl/Cmd handling too, not just the app's. - // Returning without this would suppress our handlers while still - // letting find-in-page or zoom fire behind the modal. - if (event.ctrlKey || event.metaKey) event.preventDefault(); - // Plain keys are deliberately left alone: the dialog's focus trap owns - // Tab and its own listener owns Escape, and preventDefault here would - // break both. - return; - } - - const ctrl = event.ctrlKey || event.metaKey; + const suppressibleShortcut = + ((event.ctrlKey || event.metaKey) && !event.altKey) || + /^F\d{1,2}$/.test(event.key); const isInput = isTypingTarget(event.target); - const isDialogOpen = - showFindBarOpen || + + // Modal surfaces own Escape/Tab handling, but global app and browser + // shortcuts must not operate on content hidden behind them. + const modalSurfaceOpen = + elevationPromptOpen || showFilterDialogOpen || showErrorLookupDialogOpen || showAboutDialogOpen || showSettingsDialogOpen || showEvidenceBundleDialogOpen || - showFileAssociationPromptOpen; + showGuidRegistryDialogOpen || + showMergeTabsDialogOpen || + showDiffConfigDialogOpen || + showFileAssociationPromptOpen || + showCollectDiagnosticsDialogOpen || + collectionResultOpen || + showUpdateDialogOpen || + hasModalSurface(); + if (modalSurfaceOpen) { + if (suppressibleShortcut && !isNativeTextEditingShortcut(event, isInput)) { + event.preventDefault(); + } + return; + } + + const ctrl = event.ctrlKey || event.metaKey; + const isDialogOpen = showFindBarOpen || modalSurfaceOpen; if (ctrl && !isInput && commandState.canAdjustTextSize) { const normalizedKey = event.key.toLowerCase(); @@ -340,6 +376,7 @@ export function useKeyboard() { commandState.canAdjustTextSize, decreaseLogListTextSize, dismissTransientDialogs, + collectionResultOpen, elevationPromptOpen, findNext, findPrevious, @@ -355,6 +392,11 @@ export function useKeyboard() { showFilterDialog, showFilterDialogOpen, showFileAssociationPromptOpen, + showCollectDiagnosticsDialogOpen, + showDiffConfigDialogOpen, + showGuidRegistryDialogOpen, + showMergeTabsDialogOpen, + showUpdateDialogOpen, showFindBar, showFindBarOpen, toggleDetailsPane, diff --git a/src/hooks/use-modal-focus.ts b/src/hooks/use-modal-focus.ts new file mode 100644 index 000000000..1519979a8 --- /dev/null +++ b/src/hooks/use-modal-focus.ts @@ -0,0 +1,107 @@ +import { useEffect, type RefObject } from "react"; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +function getFocusableControls(surface: HTMLElement): HTMLElement[] { + return Array.from( + surface.querySelectorAll(FOCUSABLE_SELECTOR), + ); +} + +export function useModalFocus( + isOpen: boolean, + surfaceRef: RefObject, + initialFocusRef?: RefObject, + focusKey?: string | number | null, +): void { + useEffect(() => { + if (!isOpen) return; + + const previouslyFocused = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const surface = surfaceRef.current; + const preferred = initialFocusRef?.current; + const target = + preferred && !preferred.hasAttribute("disabled") + ? preferred + : surface?.querySelector(FOCUSABLE_SELECTOR) ?? surface; + target?.focus(); + return () => { + if (previouslyFocused?.isConnected) { + previouslyFocused.focus(); + } + }; + }, [initialFocusRef, isOpen, surfaceRef]); + useEffect(() => { + if (!isOpen || focusKey == null) return; + + const surface = surfaceRef.current; + if (!surface) return; + + const focusable = getFocusableControls(surface); + const active = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + if (active && focusable.includes(active)) return; + + const preferred = initialFocusRef?.current; + const target = + preferred && !preferred.hasAttribute("disabled") + ? preferred + : focusable[0] ?? surface; + target.focus(); + }, [focusKey, initialFocusRef, isOpen, surfaceRef]); + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + + const surface = surfaceRef.current; + if (!surface) return; + + const focusable = getFocusableControls(surface); + if (focusable.length === 0) { + event.preventDefault(); + surface.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + + if (!active || !focusable.includes(active)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + return; + } + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, surfaceRef]); + +} diff --git a/src/hooks/use-parse-progress-listener.test.tsx b/src/hooks/use-parse-progress-listener.test.tsx new file mode 100644 index 000000000..395fd77fc --- /dev/null +++ b/src/hooks/use-parse-progress-listener.test.tsx @@ -0,0 +1,159 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLogStore } from "../stores/log-store"; +import { useParseProgressListener } from "./use-parse-progress-listener"; + +const eventMocks = vi.hoisted(() => ({ + listener: null as ((event: { payload: unknown }) => void) | null, + unlisten: vi.fn(), + listen: vi.fn( + async (_event: string, listener: (event: { payload: unknown }) => void) => { + eventMocks.listener = listener; + return eventMocks.unlisten; + }, + ), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: eventMocks.listen, +})); + +interface ProgressFixture { + requestId: number; + filePath: string; + fileName: string; + completed: number; + total: number; + globalCompleted: number; + entries: number; + fileSize: number; + parseMs: number; +} + +function progress(overrides: Partial = {}): ProgressFixture { + return { + requestId: 7, + filePath: "C:/Logs/App.log", + fileName: "App.log", + completed: 1, + total: 3, + globalCompleted: 1, + entries: 2, + fileSize: 100, + parseMs: 4, + ...overrides, + }; +} + +function activateProgress(requestId = 7, total = 3): void { + useLogStore.setState({ + folderLoadRequestId: requestId, + folderLoadProgress: 0, + folderLoadTotalFiles: total, + folderLoadCompletedFiles: 0, + folderLoadCurrentFile: "", + }); +} + +describe("useParseProgressListener", () => { + beforeEach(() => { + vi.clearAllMocks(); + eventMocks.listener = null; + useLogStore.getState().clear(); + }); + + afterEach(() => cleanup()); + + it("updates active progress from a valid event", async () => { + activateProgress(); + renderHook(() => useParseProgressListener()); + await waitFor(() => expect(eventMocks.listener).not.toBeNull()); + + act(() => { + eventMocks.listener?.({ + payload: progress({ completed: 2, globalCompleted: 2 }), + }); + }); + + const state = useLogStore.getState(); + expect(state.folderLoadProgress).toBeCloseTo(2 / 3); + expect(state.folderLoadCompletedFiles).toBe(2); + expect(state.folderLoadCurrentFile).toBe("App.log"); + }); + + it("ignores malformed, inactive, mismatched, and out-of-range events", async () => { + activateProgress(); + renderHook(() => useParseProgressListener()); + await waitFor(() => expect(eventMocks.listener).not.toBeNull()); + + const malformed: unknown[] = [ + null, + "not an object", + { ...progress(), completed: Number.NaN }, + { ...progress(), fileName: 42 }, + { ...progress(), total: 0 }, + { ...progress(), globalCompleted: 4 }, + { ...progress(), requestId: 8 }, + ]; + for (const payload of malformed) { + act(() => { + eventMocks.listener?.({ payload }); + }); + } + + expect(useLogStore.getState().folderLoadCompletedFiles).toBe(0); + expect(useLogStore.getState().folderLoadCurrentFile).toBe(""); + expect(useLogStore.getState().folderLoadProgress).toBe(0); + + act(() => { + useLogStore.getState().setFolderLoadProgress(null); + eventMocks.listener?.({ payload: progress() }); + }); + expect(useLogStore.getState().folderLoadCompletedFiles).toBeNull(); + expect(useLogStore.getState().folderLoadProgress).toBeNull(); + }); + + it("keeps progress monotonic when Rayon events arrive out of order", async () => { + activateProgress(); + renderHook(() => useParseProgressListener()); + await waitFor(() => expect(eventMocks.listener).not.toBeNull()); + + act(() => { + eventMocks.listener?.({ + payload: progress({ completed: 2, globalCompleted: 2 }), + }); + eventMocks.listener?.({ + payload: progress({ completed: 1, globalCompleted: 1 }), + }); + }); + expect(useLogStore.getState().folderLoadCompletedFiles).toBe(2); + + act(() => { + eventMocks.listener?.({ + payload: progress({ completed: 3, globalCompleted: 3 }), + }); + }); + expect(useLogStore.getState().folderLoadCompletedFiles).toBe(3); + }); + + it("resets ownership before a new request event arrives", async () => { + activateProgress(7); + renderHook(() => useParseProgressListener()); + await waitFor(() => expect(eventMocks.listener).not.toBeNull()); + + act(() => { + eventMocks.listener?.({ payload: progress({ globalCompleted: 3 }) }); + useLogStore.setState({ + folderLoadRequestId: 8, + folderLoadProgress: 0, + folderLoadTotalFiles: 3, + folderLoadCompletedFiles: 0, + folderLoadCurrentFile: "", + }); + eventMocks.listener?.({ + payload: progress({ requestId: 8, globalCompleted: 1 }), + }); + }); + expect(useLogStore.getState().folderLoadCompletedFiles).toBe(1); + }); +}); diff --git a/src/hooks/use-parse-progress-listener.ts b/src/hooks/use-parse-progress-listener.ts index 7cd7fa869..fd11e5748 100644 --- a/src/hooks/use-parse-progress-listener.ts +++ b/src/hooks/use-parse-progress-listener.ts @@ -5,77 +5,120 @@ import { useLogStore } from "../stores/log-store"; const PARSE_PROGRESS_EVENT = "parse-progress"; interface ParseProgressPayload { + /** Source-load generation that owns this batch. */ + requestId: number; filePath: string; fileName: string; /** Files completed within the current batch (1-based). */ completed: number; /** Total files in the current batch. */ total: number; + /** Files completed across all sequential batches for this source load. */ + globalCompleted: number; entries: number; fileSize: number; parseMs: number; } +function isParseProgressPayload(value: unknown): value is ParseProgressPayload { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const payload = value as Record; + const isSafeNonNegativeInteger = (candidate: unknown): candidate is number => + typeof candidate === "number" && + Number.isSafeInteger(candidate) && + candidate >= 0; + + return ( + isSafeNonNegativeInteger(payload.requestId) && + typeof payload.filePath === "string" && + typeof payload.fileName === "string" && + isSafeNonNegativeInteger(payload.completed) && + payload.completed >= 1 && + isSafeNonNegativeInteger(payload.total) && + payload.total >= 1 && + payload.completed <= payload.total && + isSafeNonNegativeInteger(payload.globalCompleted) && + payload.globalCompleted >= payload.completed && + isSafeNonNegativeInteger(payload.entries) && + isSafeNonNegativeInteger(payload.fileSize) && + isSafeNonNegativeInteger(payload.parseMs) + ); +} + /** * Listens for `parse-progress` events emitted by the Rust backend as - * individual files finish parsing inside `parse_files_batch`. Updates - * the log store's folder-load-progress so the UI can show real-time - * per-file progress instead of only updating between batches. + * individual files finish parsing inside `parse_files_batch`. Updates the log + * store's folder-load-progress so the UI can show real-time per-file progress + * instead of only updating between batches. * - * The Rust side emits per-batch counters, but the UI needs a global - * count across all batches. We maintain a running offset that is - * reset by an effect each time a new folder load begins - * (folderLoadProgress transitions from null → non-null), so progress - * from a previous load can never bleed into the next one. + * Rust emits both a per-batch counter and a monotonic global counter. The + * global counter remains correct when Rayon delivers per-file events out of + * order, while the request ID prevents a superseded source load from writing + * into the active one. */ export function useParseProgressListener() { - // Subscribe to a derived boolean so this hook re-renders only on the - // null ↔ non-null transition instead of on every progress tick during - // a large folder load. - const isFolderLoading = useLogStore((state) => state.folderLoadProgress !== null); + const isFolderLoading = useLogStore( + (state) => state.folderLoadProgress !== null, + ); + const folderLoadRequestId = useLogStore((state) => state.folderLoadRequestId); const globalCompletedRef = useRef(0); - const prevBatchCompletedRef = useRef(0); - const wasLoadingRef = useRef(false); + const trackedRequestIdRef = useRef(null); useEffect(() => { - if (isFolderLoading && !wasLoadingRef.current) { + if (!isFolderLoading || folderLoadRequestId === null) { globalCompletedRef.current = 0; - prevBatchCompletedRef.current = 0; + trackedRequestIdRef.current = null; + return; } - wasLoadingRef.current = isFolderLoading; - }, [isFolderLoading]); + if (trackedRequestIdRef.current !== folderLoadRequestId) { + globalCompletedRef.current = 0; + trackedRequestIdRef.current = folderLoadRequestId; + } + }, [folderLoadRequestId, isFolderLoading]); useEffect(() => { const unlisten = listen( PARSE_PROGRESS_EVENT, (event) => { - const p = event.payload; - const state = useLogStore.getState(); + if (!isParseProgressPayload(event.payload)) { + return; + } - // Only update if a folder load is currently in progress. The reset - // for the next load is handled by the effect above on the - // null → non-null transition. + const state = useLogStore.getState(); if (state.folderLoadProgress === null) { return; } - - // Detect new batch: per-batch completed count resets to a lower value - if (p.completed < prevBatchCompletedRef.current) { - // New batch started — promote previous batch count to global offset - globalCompletedRef.current += prevBatchCompletedRef.current; + if (event.payload.requestId !== state.folderLoadRequestId) { + return; } - prevBatchCompletedRef.current = p.completed; - const globalCompleted = globalCompletedRef.current + p.completed; - const globalTotal = state.folderLoadTotalFiles ?? p.total; + // Reset synchronously from the event's ownership boundary. The + // request-id effect normally keeps this ref current, but an event can + // arrive before React flushes that effect after a new load starts. + if (trackedRequestIdRef.current !== state.folderLoadRequestId) { + trackedRequestIdRef.current = state.folderLoadRequestId; + globalCompletedRef.current = 0; + } + const globalTotal = state.folderLoadTotalFiles; + if ( + globalTotal === null || + event.payload.globalCompleted > globalTotal || + event.payload.globalCompleted <= globalCompletedRef.current + ) { + return; + } + globalCompletedRef.current = event.payload.globalCompleted; state.setFolderLoadProgress({ - current: globalCompleted, + current: event.payload.globalCompleted, total: globalTotal, - currentFile: p.fileName, + currentFile: event.payload.fileName, }); - } + }, ); return () => { diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index c5665ec82..028999d5a 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -5,6 +5,7 @@ import { authorizeSccmAdvancedCapture, cancelSccmAdvancedCapture, captureSccmAdvancedDiagnostics, + analyzeIntuneLogs, discoverSccmEnvironment, getSafeErrorMessage, graphGetAuthStatus, @@ -12,8 +13,12 @@ import { graphCancelAuthentication, graphReserveInteractiveOperation, graphRequestMissingPermissions, + buildTimeline, + listLogFolder, openLogFile, + parseFilesBatch, revealInFileManager, + inspectEvidenceArtifact, } from "./commands"; import { readAccessDenied } from "./source-error"; @@ -80,6 +85,257 @@ beforeEach(() => { vi.mocked(invoke).mockReset(); }); +describe("parse and folder IPC response validation", () => { + it("preserves valid parser and folder responses", async () => { + const parseResult = { + entries: [ + { + id: 0, + lineNumber: 1, + message: "line", + component: null, + timestamp: null, + timestampDisplay: null, + severity: "Info", + thread: null, + threadDisplay: null, + sourceFile: null, + format: "Simple", + filePath: "C:\\Logs\\App.log", + timezoneOffset: null, + }, + ], + formatDetected: "Simple", + parserSelection: { + parser: "simple", + implementation: "simple", + provenance: "dedicated", + parseQuality: "structured", + recordFraming: "physicalLine", + dateOrder: null, + specialization: null, + }, + totalLines: 0, + parseErrors: 0, + filePath: "C:\\Logs\\App.log", + fileSize: 0, + byteOffset: 0, + }; + const folderListing = { + sourceKind: "folder", + source: { kind: "folder", path: "C:\\Logs" }, + entries: [], + bundleMetadata: null, + }; + vi.mocked(invoke) + .mockResolvedValueOnce([parseResult]) + .mockResolvedValueOnce(folderListing); + + await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).resolves.toEqual( + [parseResult], + ); + expect(invoke).toHaveBeenCalledWith("parse_files_batch", { + paths: ["C:\\Logs\\App.log"], + requestId: 7, + completedOffset: 0, + }); + await expect(listLogFolder("C:\\Logs")).resolves.toEqual(folderListing); + }); + + it("rejects malformed parser and folder responses", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce({ filePath: "C:\\Logs\\App.log" }) + .mockResolvedValueOnce([{ filePath: "C:\\Logs\\App.log" }]) + .mockResolvedValueOnce({ + sourceKind: "folder", + source: { kind: "folder", path: "C:\\Logs" }, + entries: [{ name: "App.log", path: "C:\\Logs\\App.log" }], + }); + + await expect(openLogFile("C:\\Logs\\App.log")).rejects.toThrow( + "Command 'open_log_file' returned an invalid response.", + ); + await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).rejects.toThrow( + "Command 'parse_files_batch' returned an invalid response.", + ); + await expect(listLogFolder("C:\\Logs")).rejects.toThrow( + "Command 'list_log_folder' returned an invalid response.", + ); + }); +}); +function validIntuneAnalysis() { + return { + events: [], + downloads: [], + summary: {}, + diagnostics: [], + sourceFile: "C:\\Logs\\IntuneManagementExtension.log", + sourceFiles: [], + diagnosticsCoverage: {}, + diagnosticsConfidence: {}, + repeatedFailures: [], + guidRegistry: {}, + }; +} + +describe("Intune IPC response validation", () => { + it("accepts structured diagnostics metadata", async () => { + const result = validIntuneAnalysis(); + vi.mocked(invoke).mockResolvedValueOnce(result); + + await expect( + analyzeIntuneLogs("C:\\Logs", "request-1"), + ).resolves.toEqual(result); + }); + + it("rejects malformed diagnostics metadata", async () => { + const result = { + ...validIntuneAnalysis(), + diagnosticsCoverage: "complete", + diagnosticsConfidence: "high", + }; + vi.mocked(invoke).mockResolvedValueOnce(result); + + await expect( + analyzeIntuneLogs("C:\\Logs", "request-1"), + ).rejects.toThrow("invalid response"); + }); +}); + + +function validTimelineBundle() { + return { + id: "timeline-1", + sources: [ + { + idx: 0, + kind: "intuneEvents", + path: "C:\\Logs", + displayName: "Logs", + color: "#2563eb", + entryCount: 1, + }, + { + idx: 1, + kind: { logFile: { parserKind: "ccm" } }, + path: "C:\\Logs\\App.log", + displayName: "App.log", + color: "#16a34a", + entryCount: 1, + }, + ], + timeRangeMs: [100, 200], + totalEntries: 2, + incidents: [ + { + id: 0, + tsStartMs: 100, + tsEndMs: 200, + signalCount: 2, + sourceCount: 2, + confidence: 0.5, + anchorEventRef: null, + anchorGuid: null, + summary: "Overlapping failures", + }, + ], + deniedGuids: [], + errors: [], + tunables: { + overlapWindowMs: 5_000, + minSourceCount: 2, + maxIncidentSpanMs: 60_000, + enabledSignalKinds: ["errorSeverity"], + }, + }; +} + +describe("timeline IPC response validation", () => { + it("preserves a valid timeline bundle", async () => { + const bundle = validTimelineBundle(); + vi.mocked(invoke).mockResolvedValue(bundle); + + await expect( + buildTimeline([{ path: "C:\\Logs", displayName: "Logs" }]), + ).resolves.toEqual(bundle); + expect(invoke).toHaveBeenCalledWith("build_timeline_cmd", { + sources: [{ path: "C:\\Logs", displayName: "Logs" }], + }); + }); + + it("rejects a timeline bundle with malformed nested source data", async () => { + const bundle = validTimelineBundle(); + const malformedBundle = { + ...bundle, + sources: [ + { + ...bundle.sources[0], + kind: { logFile: { parserKind: "not-a-parser" } }, + }, + ], + }; + vi.mocked(invoke).mockResolvedValue(malformedBundle); + + await expect(buildTimeline([{ path: "C:\\Logs" }])).rejects.toThrow( + "Command 'build_timeline_cmd' returned an invalid response.", + ); + }); +}); + +function validEvidenceArtifactPreview() { + return { + path: "C:\\Logs\\snapshot.reg", + intakeKind: "registrySnapshot", + summary: "Parsed registry snapshot.", + registrySnapshot: { + keyCount: 1, + valueCount: 1, + keys: [ + { + path: "HKLM\\Software\\Contoso", + valueCount: 1, + values: [ + { + name: "Enabled", + valueType: "dword", + value: "0x00000001 (1)", + }, + ], + }, + ], + }, + eventLogExport: null, + }; +} + +describe("evidence artifact IPC response validation", () => { + it("preserves validated nested preview metadata", async () => { + const preview = validEvidenceArtifactPreview(); + vi.mocked(invoke).mockResolvedValue(preview); + + await expect( + inspectEvidenceArtifact("C:\\Logs\\snapshot.reg", "registrySnapshot"), + ).resolves.toEqual(preview); + }); + + it("rejects malformed nested preview metadata", async () => { + const preview = validEvidenceArtifactPreview(); + vi.mocked(invoke).mockResolvedValue({ + ...preview, + registrySnapshot: { + ...preview.registrySnapshot, + keys: [{ path: "HKLM\\Software\\Contoso", valueCount: 1 }], + }, + }); + + await expect( + inspectEvidenceArtifact("C:\\Logs\\snapshot.reg", "registrySnapshot"), + ).rejects.toThrow( + "Command 'inspect_evidence_artifact' returned an invalid response.", + ); + }); +}); + function validGraphStatus() { return { isAuthenticated: true, @@ -101,7 +357,14 @@ function validGraphStatus() { describe("SCCM product-path IPC boundary", () => { it("invokes discovery and capture without accepting frontend inputs", async () => { - const discovery = { supported: true, roles: [], sources: [], issues: [] }; + const discovery = { + supported: true, + configmgrVersion: null, + roles: [], + sources: [], + issues: [], + advancedSources: [], + }; const capture = { bundleRoot: "C:\\capture", capturedAtUtc: "2026-08-04T14:30:00Z", @@ -155,13 +418,22 @@ describe("SCCM product-path IPC boundary", () => { pathClass: request.pathClass, sourceVersion: request.expectedSourceVersion, }; - const result = { bundleRoot: "C:\\bundle", sources: [] }; + const result = { + bundleRoot: "C:\\bundle", + capturedAtUtc: "2026-08-04T14:30:00Z", + roles: [], + sources: [], + artifactCount: 0, + retainedBytes: 0, + }; vi.mocked(invoke) .mockResolvedValueOnce(capability) .mockResolvedValueOnce(result) .mockResolvedValueOnce(undefined); - await expect(authorizeSccmAdvancedCapture(request)).resolves.toBe(capability); + await expect(authorizeSccmAdvancedCapture(request)).resolves.toBe( + capability, + ); await expect( captureSccmAdvancedDiagnostics(capability.capabilityHandle), ).resolves.toBe(result); @@ -169,9 +441,13 @@ describe("SCCM product-path IPC boundary", () => { cancelSccmAdvancedCapture(capability.capabilityHandle), ).resolves.toBeUndefined(); - expect(invoke).toHaveBeenNthCalledWith(1, "authorize_sccm_advanced_capture", { - request, - }); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "authorize_sccm_advanced_capture", + { + request, + }, + ); expect(invoke).toHaveBeenNthCalledWith( 2, "capture_sccm_advanced_diagnostics", diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 0f1174d4b..93feab461 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -6,6 +6,7 @@ import type { LogFormat, LogSource, ParseResult, + ParserKind, WorkspaceId, } from "../types/log"; import type { @@ -49,6 +50,7 @@ import type { SccmAdvancedCaptureCapability, SccmEnvironmentDiscovery, } from "../workspaces/sccm/types"; +import type { SignalKind, TimelineBundle } from "../types/timeline"; export interface FileAssociationPromptStatus { supported: boolean; @@ -277,37 +279,567 @@ function normalizeCommandInvokeError( return normalizedError; } -async function invokeCommand( - commandName: string, +type CommandDecoder = (value: unknown, commandName: string) => T; + +async function invokeCommand( + commandName: Name, args?: Record, -): Promise { +): Promise>; +async function invokeCommand( + commandName: CommandName, + args?: Record, +): Promise> { + let response: unknown; try { - return await invoke(commandName, args); + response = await invoke(commandName, args); } catch (error) { throw normalizeCommandInvokeError(commandName, error); } + + const decoder = COMMAND_DECODERS[commandName]; + if (!decoder) { + throw new Error(`No response decoder registered for '${commandName}'.`); + } + return decoder(response, commandName); +} + +function isCommandRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteCommandNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isNullableCommandNumber(value: unknown): value is number | null { + return value === null || isFiniteCommandNumber(value); +} + +function isNullableCommandString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isParserSelectionResponse(value: unknown): boolean { + return ( + isCommandRecord(value) && + typeof value.parser === "string" && + typeof value.implementation === "string" && + typeof value.provenance === "string" && + typeof value.parseQuality === "string" && + typeof value.recordFraming === "string" && + isNullableCommandString(value.dateOrder) && + (value.specialization === undefined || + isNullableCommandString(value.specialization)) + ); +} + +function isLogEntryResponse(value: unknown): boolean { + return ( + isCommandRecord(value) && + isFiniteCommandNumber(value.id) && + isFiniteCommandNumber(value.lineNumber) && + typeof value.message === "string" && + isNullableCommandString(value.component) && + isNullableCommandNumber(value.timestamp) && + isNullableCommandString(value.timestampDisplay) && + typeof value.severity === "string" && + isNullableCommandNumber(value.thread) && + isNullableCommandString(value.threadDisplay) && + isNullableCommandString(value.sourceFile) && + typeof value.format === "string" && + typeof value.filePath === "string" && + isNullableCommandNumber(value.timezoneOffset) + ); +} + +function isParseResultResponse(value: unknown): value is ParseResult { + return ( + isCommandRecord(value) && + Array.isArray(value.entries) && + value.entries.every(isLogEntryResponse) && + typeof value.formatDetected === "string" && + isParserSelectionResponse(value.parserSelection) && + isFiniteCommandNumber(value.totalLines) && + isFiniteCommandNumber(value.parseErrors) && + typeof value.filePath === "string" && + isFiniteCommandNumber(value.fileSize) && + isFiniteCommandNumber(value.byteOffset) + ); +} + +function isLogSourceKind(value: unknown): boolean { + return value === "file" || value === "folder" || value === "known"; +} + +function isLogSourceResponse(value: unknown): boolean { + if (!isCommandRecord(value) || !isLogSourceKind(value.kind)) { + return false; + } + if (value.kind === "file" || value.kind === "folder") { + return typeof value.path === "string"; + } + return ( + typeof value.sourceId === "string" && + typeof value.defaultPath === "string" && + (value.pathKind === "file" || value.pathKind === "folder") + ); +} + +function isFolderEntryResponse(value: unknown): boolean { + return ( + isCommandRecord(value) && + typeof value.name === "string" && + typeof value.path === "string" && + typeof value.isDir === "boolean" && + isNullableCommandNumber(value.sizeBytes) && + isNullableCommandNumber(value.modifiedUnixMs) + ); +} + +function isFolderListingResponse(value: unknown): value is FolderListingResult { + return ( + isCommandRecord(value) && + isLogSourceKind(value.sourceKind) && + isLogSourceResponse(value.source) && + Array.isArray(value.entries) && + value.entries.every(isFolderEntryResponse) && + (value.bundleMetadata === undefined || + value.bundleMetadata === null || + isCommandRecord(value.bundleMetadata)) + ); +} +const TIMELINE_PARSER_KIND_MEMBERS = { + ccm: true, + simple: true, + timestamped: true, + plain: true, + iisW3c: true, + panther: true, + cbs: true, + dism: true, + reportingEvents: true, + msi: true, + psadtLegacy: true, + intuneMacOs: true, + intuneDeviceInventory: true, + dhcp: true, + burn: true, + patchMyPcDetection: true, + registry: true, + secureBootLog: true, + dnsDebug: true, + dnsAudit: true, + cmtLog: true, + companyPortal: true, +} satisfies Record; + +const TIMELINE_PARSER_KINDS = new Set(Object.keys(TIMELINE_PARSER_KIND_MEMBERS)); + +const TIMELINE_SIGNAL_KIND_MEMBERS = { + errorSeverity: true, + knownErrorCode: true, + imeFailed: true, +} satisfies Record; + +const TIMELINE_SIGNAL_KINDS = new Set(Object.keys(TIMELINE_SIGNAL_KIND_MEMBERS)); + +function isTimelineSourceKind(value: unknown): boolean { + if (value === "intuneEvents") return true; + if (!isCommandRecord(value) || !isCommandRecord(value.logFile)) { + return false; + } + return ( + typeof value.logFile.parserKind === "string" && + TIMELINE_PARSER_KINDS.has(value.logFile.parserKind) + ); +} + +function isTimelineSourceMeta(value: unknown): boolean { + return ( + isCommandRecord(value) && + isFiniteCommandNumber(value.idx) && + isTimelineSourceKind(value.kind) && + typeof value.path === "string" && + typeof value.displayName === "string" && + typeof value.color === "string" && + isFiniteCommandNumber(value.entryCount) + ); +} + +function isTimelineIncident(value: unknown): boolean { + return ( + isCommandRecord(value) && + isFiniteCommandNumber(value.id) && + isFiniteCommandNumber(value.tsStartMs) && + isFiniteCommandNumber(value.tsEndMs) && + isFiniteCommandNumber(value.signalCount) && + isFiniteCommandNumber(value.sourceCount) && + isFiniteCommandNumber(value.confidence) && + (value.anchorEventRef === undefined || + value.anchorEventRef === null || + (Array.isArray(value.anchorEventRef) && + value.anchorEventRef.length === 2 && + value.anchorEventRef.every(isFiniteCommandNumber))) && + (value.anchorGuid === undefined || + value.anchorGuid === null || + typeof value.anchorGuid === "string") && + typeof value.summary === "string" + ); +} + +function isTimelineError(value: unknown): boolean { + return ( + isCommandRecord(value) && + typeof value.path === "string" && + typeof value.message === "string" + ); +} + +function isTimelineTunables(value: unknown): boolean { + return ( + isCommandRecord(value) && + isFiniteCommandNumber(value.overlapWindowMs) && + isFiniteCommandNumber(value.minSourceCount) && + isFiniteCommandNumber(value.maxIncidentSpanMs) && + Array.isArray(value.enabledSignalKinds) && + value.enabledSignalKinds.every( + (kind) => typeof kind === "string" && TIMELINE_SIGNAL_KINDS.has(kind), + ) + ); +} + +function isTimelineBundleResponse(value: unknown): value is TimelineBundle { + return ( + isCommandRecord(value) && + typeof value.id === "string" && + Array.isArray(value.sources) && + value.sources.every(isTimelineSourceMeta) && + Array.isArray(value.timeRangeMs) && + value.timeRangeMs.length === 2 && + value.timeRangeMs.every(isFiniteCommandNumber) && + isFiniteCommandNumber(value.totalEntries) && + Array.isArray(value.incidents) && + value.incidents.every(isTimelineIncident) && + isStringArray(value.deniedGuids) && + Array.isArray(value.errors) && + value.errors.every(isTimelineError) && + isTimelineTunables(value.tunables) + ); +} + +function decodeTimelineBundle( + value: unknown, + commandName: string, +): TimelineBundle { + if (!isTimelineBundleResponse(value)) { + return invalidCommandResponse(commandName); + } + return value; +} + +function invalidCommandResponse(commandName: string): never { + throw new Error(`Command '${commandName}' returned an invalid response.`); +} + +type CommandFieldValidator = (value: unknown) => boolean; + +function hasCommandFields( + value: Record, + fields: Record, +): boolean { + return Object.entries(fields).every(([key, validator]) => + validator(value[key]), + ); +} + +function decodeRecordResponse( + value: unknown, + commandName: string, + fields: Record = {}, +): T { + if (!isCommandRecord(value) || !hasCommandFields(value, fields)) { + return invalidCommandResponse(commandName); + } + return value as T; +} + +function decodeRecordArrayResponse( + value: unknown, + commandName: string, + fields: Record = {}, +): T { + if ( + !Array.isArray(value) || + !value.every( + (item) => isCommandRecord(item) && hasCommandFields(item, fields), + ) + ) { + return invalidCommandResponse(commandName); + } + return value as T; +} + +function decodeStringArrayResponse( + value: unknown, + commandName: string, +): string[] { + if ( + !Array.isArray(value) || + !value.every((item) => typeof item === "string") + ) { + return invalidCommandResponse(commandName); + } + return value; +} + +function decodeStringResponse(value: unknown, commandName: string): string { + if (typeof value !== "string") return invalidCommandResponse(commandName); + return value; +} + +function decodeBooleanResponse(value: unknown, commandName: string): boolean { + if (typeof value !== "boolean") return invalidCommandResponse(commandName); + return value; +} + +function decodeNullableRecordResponse( + value: unknown, + commandName: string, + fields: Record = {}, +): T | null { + if (value === null) return null; + return decodeRecordResponse(value, commandName, fields); +} + +function decodeUnitResponse(value: unknown, commandName: string): void { + if (value !== null && value !== undefined) { + return invalidCommandResponse(commandName); + } +} + +function decodeWorkspaceIdResponse( + value: unknown, + commandName: string, +): WorkspaceId { + if (!isWorkspaceIdValue(value)) { + return invalidCommandResponse(commandName); + } + return value; +} + +function decodeNullableWorkspaceIdResponse( + value: unknown, + commandName: string, +): WorkspaceId | null { + return value === null ? null : decodeWorkspaceIdResponse(value, commandName); +} + +function decodePathKindResponse( + value: unknown, + commandName: string, +): "file" | "folder" | "unknown" { + if (value !== "file" && value !== "folder" && value !== "unknown") { + return invalidCommandResponse(commandName); + } + return value; +} + +const WORKSPACE_IDS: Record = { + log: true, + intune: true, + "new-intune": true, + dsregcmd: true, + "macos-diag": true, + "macos-jamf": true, + deployment: true, + "event-log": true, + "esp-diagnostics": true, + sccm: true, + secureboot: true, + sysmon: true, + timeline: true, + "dns-dhcp": true, +}; + +function isWorkspaceIdValue(value: unknown): value is WorkspaceId { + return ( + typeof value === "string" && + Object.prototype.hasOwnProperty.call(WORKSPACE_IDS, value) + ); +} + +function decodeWorkspaceIdArrayResponse( + value: unknown, + commandName: string, +): WorkspaceId[] { + if (!Array.isArray(value) || !value.every(isWorkspaceIdValue)) { + return invalidCommandResponse(commandName); + } + return value; +} + +function isCommandRecordArray(value: unknown): boolean { + return Array.isArray(value) && value.every(isCommandRecord); +} + +function isNullableCommandRecord(value: unknown): boolean { + return value === null || isCommandRecord(value); +} +const EVIDENCE_ARTIFACT_INTAKE_KIND_MEMBERS = { + log: true, + registrySnapshot: true, + eventLogExport: true, + commandOutput: true, + screenshot: true, + export: true, + unknown: true, +} satisfies Record; + +function isEvidenceArtifactIntakeKind(value: unknown): boolean { + return ( + typeof value === "string" && + Object.prototype.hasOwnProperty.call( + EVIDENCE_ARTIFACT_INTAKE_KIND_MEMBERS, + value, + ) + ); +} + +function isRegistrySnapshotValuePreview(value: unknown): boolean { + return ( + isCommandRecord(value) && + typeof value.name === "string" && + typeof value.valueType === "string" && + typeof value.value === "string" + ); +} + +function isRegistrySnapshotKeyPreview(value: unknown): boolean { + return ( + isCommandRecord(value) && + typeof value.path === "string" && + isFiniteCommandNumber(value.valueCount) && + Array.isArray(value.values) && + value.values.every(isRegistrySnapshotValuePreview) + ); +} + +function isRegistrySnapshotSummary(value: unknown): boolean { + return ( + isCommandRecord(value) && + isFiniteCommandNumber(value.keyCount) && + isFiniteCommandNumber(value.valueCount) && + Array.isArray(value.keys) && + value.keys.every(isRegistrySnapshotKeyPreview) + ); +} + +function isEvidenceEventLogExportPreview(value: unknown): boolean { + return ( + isCommandRecord(value) && + isNullableCommandString(value.channel) && + isNullableCommandNumber(value.fileSizeBytes) && + isNullableCommandNumber(value.modifiedUnixMs) && + typeof value.exportFormat === "string" + ); +} + +function isNullableRegistrySnapshotSummary(value: unknown): boolean { + return value === null || isRegistrySnapshotSummary(value); +} + +function isNullableEvidenceEventLogExportPreview(value: unknown): boolean { + return value === null || isEvidenceEventLogExportPreview(value); +} + + +function decodeParseResults( + value: unknown, + commandName: string, +): ParseResult[] { + if (!Array.isArray(value) || !value.every(isParseResultResponse)) { + return invalidCommandResponse(commandName); + } + return value; +} + +function decodeParseResult(value: unknown, commandName: string): ParseResult { + if (!isParseResultResponse(value)) { + return invalidCommandResponse(commandName); + } + return value; +} + +function decodeAggregateParseResult( + value: unknown, + commandName: string, +): AggregateParseResult { + return decodeRecordResponse(value, commandName, { + entries: (entries) => + Array.isArray(entries) && entries.every(isLogEntryResponse), + totalLines: isFiniteCommandNumber, + parseErrors: isFiniteCommandNumber, + folderPath: (path) => typeof path === "string", + files: (files) => + Array.isArray(files) && + files.every( + (file) => + isCommandRecord(file) && + typeof file.filePath === "string" && + isFiniteCommandNumber(file.totalLines) && + isFiniteCommandNumber(file.parseErrors) && + isFiniteCommandNumber(file.fileSize) && + isFiniteCommandNumber(file.byteOffset), + ), + }); +} + +function decodeFolderListingResult( + value: unknown, + commandName: string, +): FolderListingResult { + if (!isFolderListingResponse(value)) { + return invalidCommandResponse(commandName); + } + return value; } export async function openLogFile(path: string): Promise { - return invokeCommand("open_log_file", { path }); + return invokeCommand("open_log_file", { path }); } /** Parse multiple files in parallel on the Rust side (Rayon thread pool). - * Returns all results in a single IPC response — eliminates N-1 round-trips. */ -export async function parseFilesBatch(paths: string[]): Promise { - return invokeCommand("parse_files_batch", { paths }); + * Returns all results in a single IPC response. The request ID tags progress + * events to the owning source load and the offset makes progress monotonic + * across sequential batches. */ +export async function parseFilesBatch( + paths: string[], + requestId: number, + completedOffset: number, +): Promise { + return invokeCommand("parse_files_batch", { + paths, + requestId, + completedOffset, + }); } export async function listLogFolder( path: string, ): Promise { - return invokeCommand("list_log_folder", { path }); + return invokeCommand("list_log_folder", { path }); +} + +export async function buildTimeline( + sources: { path: string; displayName?: string }[], +): Promise { + return invokeCommand("build_timeline_cmd", { sources }); } export async function inspectEvidenceBundle( path: string, ): Promise { - return invokeCommand("inspect_evidence_bundle", { + return invokeCommand("inspect_evidence_bundle", { path, }); } @@ -317,7 +849,7 @@ export async function inspectEvidenceArtifact( intakeKind: EvidenceArtifactIntakeKind, originPath?: string | null, ): Promise { - return invokeCommand("inspect_evidence_artifact", { + return invokeCommand("inspect_evidence_artifact", { path, intakeKind, originPath: originPath ?? null, @@ -327,11 +859,11 @@ export async function inspectEvidenceArtifact( export async function parseRegistryFile( path: string, ): Promise { - return invokeCommand("parse_registry_file", { path }); + return invokeCommand("parse_registry_file", { path }); } export async function getKnownLogSources(): Promise { - return invokeCommand("get_known_log_sources"); + return invokeCommand("get_known_log_sources"); } export async function openLogSourceFile( @@ -369,7 +901,7 @@ export async function listLogSourceFolder( export async function openLogFolderAggregate( path: string, ): Promise { - return invokeCommand("open_log_folder_aggregate", { + return invokeCommand("open_log_folder_aggregate", { path, }); } @@ -397,7 +929,7 @@ export async function startTail( nextId: number, nextLine: number, ): Promise { - return invokeCommand("start_tail", { + return invokeCommand("start_tail", { path, format, byteOffset, @@ -407,15 +939,15 @@ export async function startTail( } export async function stopTail(path: string): Promise { - return invokeCommand("stop_tail", { path }); + return invokeCommand("stop_tail", { path }); } export async function pauseTail(path: string): Promise { - return invokeCommand("pause_tail", { path }); + return invokeCommand("pause_tail", { path }); } export async function resumeTail(path: string): Promise { - return invokeCommand("resume_tail", { path }); + return invokeCommand("resume_tail", { path }); } export async function analyzeIntuneLogs( @@ -423,7 +955,7 @@ export async function analyzeIntuneLogs( requestId: string, options?: AnalyzeIntuneLogsOptions & { graphApiEnabled?: boolean }, ): Promise { - return invokeCommand("analyze_intune_logs", { + return invokeCommand("analyze_intune_logs", { path, requestId, includeLiveEventLogs: options?.includeLiveEventLogs ?? false, @@ -436,7 +968,7 @@ export async function analyzeSysmonLogs( requestId: string, options?: { includeLiveEventLogs?: boolean }, ): Promise { - return invokeCommand("analyze_sysmon_logs", { + return invokeCommand("analyze_sysmon_logs", { path, requestId, includeLiveEventLogs: options?.includeLiveEventLogs ?? false, @@ -447,20 +979,20 @@ export async function analyzeDsregcmd( input: string, bundlePath?: string | null, ): Promise { - return invokeCommand("analyze_dsregcmd", { + return invokeCommand("analyze_dsregcmd", { input, bundlePath: bundlePath ?? null, }); } export async function captureDsregcmd(): Promise { - return invokeCommand("capture_dsregcmd"); + return invokeCommand("capture_dsregcmd"); } export async function inspectPathKind( path: string, ): Promise<"file" | "folder" | "unknown"> { - return invokeCommand<"file" | "folder" | "unknown">("inspect_path_kind", { + return invokeCommand("inspect_path_kind", { path, }); } @@ -469,37 +1001,37 @@ export async function writeTextOutputFile( path: string, contents: string, ): Promise { - return invokeCommand("write_text_output_file", { path, contents }); + return invokeCommand("write_text_output_file", { path, contents }); } export async function loadDsregcmdSource( kind: "file" | "folder", path: string, ): Promise { - return invokeCommand("load_dsregcmd_source", { + return invokeCommand("load_dsregcmd_source", { kind, path, }); } export async function getInitialFilePaths(): Promise { - return invokeCommand("get_initial_file_paths"); + return invokeCommand("get_initial_file_paths"); } export async function getInitialWorkspace(): Promise { - return invokeCommand("get_initial_workspace"); + return invokeCommand("get_initial_workspace"); } // --- Application-wide elevation --- export async function getAppElevationState(): Promise { - return invokeCommand("get_app_elevation_state"); + return invokeCommand("get_app_elevation_state"); } export async function restartAsAdministrator( request: ElevationRequest, ): Promise { - return invokeCommand("restart_as_administrator", { + return invokeCommand("restart_as_administrator", { request, }); } @@ -511,34 +1043,31 @@ export async function restartAsAdministrator( * already consumed — because a failed restore must never stop the app starting. */ export async function getInitialElevationRestore(): Promise { - return invokeCommand("get_initial_elevation_restore"); + return invokeCommand("get_initial_elevation_restore"); } export async function getAvailableWorkspaces(): Promise { - return invokeCommand("get_available_workspaces"); + return invokeCommand("get_available_workspaces"); } export async function discoverSccmEnvironment(): Promise { - return invokeCommand("discover_sccm_environment"); + return invokeCommand("discover_sccm_environment"); } export async function captureSccmDiagnostics(): Promise { - return invokeCommand("capture_sccm_diagnostics"); + return invokeCommand("capture_sccm_diagnostics"); } export async function authorizeSccmAdvancedCapture( request: SccmAdvancedCaptureAuthorizationRequest, ): Promise { - return invokeCommand( - "authorize_sccm_advanced_capture", - { request }, - ); + return invokeCommand("authorize_sccm_advanced_capture", { request }); } export async function captureSccmAdvancedDiagnostics( capabilityHandle: string, ): Promise { - return invokeCommand("capture_sccm_advanced_diagnostics", { + return invokeCommand("capture_sccm_advanced_diagnostics", { capabilityHandle, }); } @@ -546,17 +1075,17 @@ export async function captureSccmAdvancedDiagnostics( export async function cancelSccmAdvancedCapture( capabilityHandle: string, ): Promise { - return invokeCommand("cancel_sccm_advanced_capture", { + return invokeCommand("cancel_sccm_advanced_capture", { capabilityHandle, }); } export async function revealInFileManager(path: string): Promise { - return invokeCommand("reveal_in_file_manager", { path }); + return invokeCommand("reveal_in_file_manager", { path }); } export async function getUpdatePolicy(): Promise { - return invokeCommand("get_update_policy"); + return invokeCommand("get_update_policy"); } export interface DnsLoggingStatus { @@ -567,11 +1096,11 @@ export interface DnsLoggingStatus { } export async function checkDnsLoggingStatus(): Promise { - return invokeCommand("check_dns_logging_status"); + return invokeCommand("check_dns_logging_status"); } export async function enableDnsDebugLogging(): Promise { - return invokeCommand("enable_dns_debug_logging"); + return invokeCommand("enable_dns_debug_logging"); } export interface DnsDhcpCollectionProgress { @@ -603,38 +1132,31 @@ export async function collectDnsDhcpFromDomain( outputRoot?: string, servers?: string[], ): Promise { - return invokeCommand( - "collect_dns_dhcp_from_domain", - { - requestId, - outputRoot: outputRoot ?? null, - servers: servers ?? null, - }, - ); + return invokeCommand("collect_dns_dhcp_from_domain", { + requestId, + outputRoot: outputRoot ?? null, + servers: servers ?? null, + }); } export async function getFileAssociationPromptStatus(): Promise { - return invokeCommand( - "get_file_association_prompt_status", - ); + return invokeCommand("get_file_association_prompt_status"); } export async function associateLogFilesWithApp(): Promise { - return invokeCommand("associate_log_files_with_app"); + return invokeCommand("associate_log_files_with_app"); } export async function setFileAssociationPromptSuppressed( suppressed: boolean, ): Promise { - return invokeCommand("set_file_association_prompt_suppressed", { + return invokeCommand("set_file_association_prompt_suppressed", { suppressed, }); } export async function getSystemDateTimePreferences(): Promise { - return invokeCommand( - "get_system_date_time_preferences", - ); + return invokeCommand("get_system_date_time_preferences"); } // --- Diagnostics Collection --- @@ -661,7 +1183,7 @@ export async function collectDiagnostics( outputRoot?: string | null, enabledFamilies?: string[] | null, ): Promise { - return invokeCommand("collect_diagnostics", { + return invokeCommand("collect_diagnostics", { requestId, outputRoot: outputRoot ?? null, enabledFamilies: enabledFamilies ?? null, @@ -671,14 +1193,14 @@ export async function collectDiagnostics( // --- ESP Diagnostics --- export async function getEspElevationState(): Promise { - return invokeCommand("get_esp_elevation_state"); + return invokeCommand("get_esp_elevation_state"); } export async function analyzeEspEvidence( path: string, requestId: string, ): Promise { - return invokeCommand("analyze_esp_evidence", { + return invokeCommand("analyze_esp_evidence", { path, requestId, }); @@ -697,7 +1219,7 @@ export async function exportEspSession( snapshot: EspDiagnosticsSnapshot, meta: EspSessionCaptureMeta, ): Promise { - return invokeCommand("export_esp_session", { + return invokeCommand("export_esp_session", { destination, snapshot, meta, @@ -707,7 +1229,7 @@ export async function exportEspSession( export async function startEspDiagnosticsSession( requestId: string, ): Promise { - return invokeCommand("start_esp_diagnostics_session", { + return invokeCommand("start_esp_diagnostics_session", { requestId, }); } @@ -715,7 +1237,7 @@ export async function startEspDiagnosticsSession( export async function getEspDiagnosticsSession( sessionId: string, ): Promise { - return invokeCommand("get_esp_diagnostics_session", { + return invokeCommand("get_esp_diagnostics_session", { sessionId, }); } @@ -723,17 +1245,17 @@ export async function getEspDiagnosticsSession( export async function stopEspDiagnosticsSession( sessionId: string, ): Promise { - return invokeCommand("stop_esp_diagnostics_session", { sessionId }); + return invokeCommand("stop_esp_diagnostics_session", { sessionId }); } export async function restartEspAsAdministrator(): Promise { - return invokeCommand("restart_esp_as_administrator"); + return invokeCommand("restart_esp_as_administrator"); } export async function graphFetchEspDiagnostics( request: EspGraphRequest, ): Promise { - return invokeCommand("graph_fetch_esp_diagnostics", { + return invokeCommand("graph_fetch_esp_diagnostics", { request, }); } @@ -741,19 +1263,19 @@ export async function graphFetchEspDiagnostics( export async function espFlipAppInstalled( appId: string, ): Promise { - return invokeCommand("esp_flip_app_installed", { appId }); + return invokeCommand("esp_flip_app_installed", { appId }); } export async function espRestoreAppState( backup: EspAppFlipBackup, ): Promise { - return invokeCommand("esp_restore_app_state", { backup }); + return invokeCommand("esp_restore_app_state", { backup }); } export async function graphCancelEspDiagnostics( requestId: string, ): Promise { - return invokeCommand("graph_cancel_esp_diagnostics", { requestId }); + return invokeCommand("graph_cancel_esp_diagnostics", { requestId }); } // --- Graph API (Windows only, opt-in) --- @@ -820,13 +1342,6 @@ export interface GraphInteractiveOperationTicket { attemptId: string; } -function isGraphRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function isNullableString(value: unknown): value is string | null { - return value === null || typeof value === "string"; -} function isStringArray(value: unknown): value is string[] { return ( @@ -835,13 +1350,13 @@ function isStringArray(value: unknown): value is string[] { } function isGraphAuthStatus(value: unknown): value is GraphAuthStatus { - if (!isGraphRecord(value) || !isGraphRecord(value.capabilities)) return false; + if (!isCommandRecord(value) || !isCommandRecord(value.capabilities)) return false; const capabilities = value.capabilities; return ( typeof value.isAuthenticated === "boolean" && - isNullableString(value.userPrincipalName) && - isNullableString(value.objectId) && - isNullableString(value.tenantId) && + isNullableCommandString(value.userPrincipalName) && + isNullableCommandString(value.objectId) && + isNullableCommandString(value.tenantId) && isStringArray(value.grantedScopes) && isStringArray(value.missingScopes) && (value.expiresAt === null || @@ -883,20 +1398,17 @@ const GRAPH_PERMISSION_UPGRADE_OUTCOMES = "stale", ]); -function invalidGraphResponse(commandName: string): never { - throw new Error(`Command '${commandName}' returned an invalid response.`); -} function decodeGraphHostCapability( value: unknown, commandName: string, ): GraphHostCapability { if ( - !isGraphRecord(value) || + !isCommandRecord(value) || typeof value.kind !== "string" || !GRAPH_HOST_CAPABILITY_KINDS.has(value.kind as GraphHostCapabilityKind) ) { - return invalidGraphResponse(commandName); + return invalidCommandResponse(commandName); } return value as unknown as GraphHostCapability; } @@ -905,7 +1417,7 @@ function decodeGraphAuthStatus( value: unknown, commandName: string, ): GraphAuthStatus { - if (!isGraphAuthStatus(value)) return invalidGraphResponse(commandName); + if (!isGraphAuthStatus(value)) return invalidCommandResponse(commandName); return value; } @@ -914,15 +1426,15 @@ function decodeGraphAuthAttemptResult( commandName: string, ): GraphAuthAttemptResult { if ( - !isGraphRecord(value) || + !isCommandRecord(value) || typeof value.outcome !== "string" || !GRAPH_AUTH_ATTEMPT_OUTCOMES.has( value.outcome as GraphAuthAttemptOutcome, ) || !isGraphAuthStatus(value.status) || - !isNullableString(value.message) + !isNullableCommandString(value.message) ) { - return invalidGraphResponse(commandName); + return invalidCommandResponse(commandName); } decodeGraphHostCapability(value.capability, commandName); return value as unknown as GraphAuthAttemptResult; @@ -933,15 +1445,15 @@ function decodeGraphPermissionUpgradeResult( commandName: string, ): GraphPermissionUpgradeResult { if ( - !isGraphRecord(value) || + !isCommandRecord(value) || typeof value.outcome !== "string" || !GRAPH_PERMISSION_UPGRADE_OUTCOMES.has( value.outcome as GraphPermissionUpgradeOutcome, ) || !isGraphAuthStatus(value.status) || - !isNullableString(value.message) + !isNullableCommandString(value.message) ) { - return invalidGraphResponse(commandName); + return invalidCommandResponse(commandName); } return value as unknown as GraphPermissionUpgradeResult; } @@ -951,13 +1463,13 @@ function decodeGraphInteractiveOperationTicket( commandName: string, ): GraphInteractiveOperationTicket { if ( - !isGraphRecord(value) || + !isCommandRecord(value) || typeof value.attemptId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( value.attemptId, ) ) { - return invalidGraphResponse(commandName); + return invalidCommandResponse(commandName); } return value as unknown as GraphInteractiveOperationTicket; } @@ -979,62 +1491,47 @@ export async function graphReserveInteractiveOperation( kind: GraphInteractiveOperationKind, ): Promise { const commandName = "graph_reserve_interactive_operation"; - return decodeGraphInteractiveOperationTicket( - await invokeCommand(commandName, { kind }), - commandName, - ); + return invokeCommand(commandName, { kind }); } export async function graphAuthenticate( attemptId: string, ): Promise { const commandName = "graph_authenticate"; - return decodeGraphAuthAttemptResult( - await invokeCommand(commandName, { attemptId }), - commandName, - ); + return invokeCommand(commandName, { attemptId }); } export async function graphCancelAuthentication( attemptId: string, ): Promise { const commandName = "graph_cancel_authentication"; - const result = await invokeCommand(commandName, { attemptId }); - return typeof result === "boolean" - ? result - : invalidGraphResponse(commandName); + return invokeCommand(commandName, { attemptId }); } export async function graphRequestMissingPermissions( attemptId: string, ): Promise { const commandName = "graph_request_missing_permissions"; - return decodeGraphPermissionUpgradeResult( - await invokeCommand(commandName, { attemptId }), - commandName, - ); + return invokeCommand(commandName, { attemptId }); } export async function graphGetAuthStatus(): Promise { const commandName = "graph_get_auth_status"; - return decodeGraphAuthStatus( - await invokeCommand(commandName), - commandName, - ); + return invokeCommand(commandName); } export async function graphSignOut(): Promise { - return invokeCommand("graph_sign_out"); + return invokeCommand("graph_sign_out"); } export async function graphResolveGuids( guids: string[], ): Promise { - return invokeCommand("graph_resolve_guids", { guids }); + return invokeCommand("graph_resolve_guids", { guids }); } export async function graphFetchAllApps(): Promise { - return invokeCommand("graph_fetch_all_apps"); + return invokeCommand("graph_fetch_all_apps"); } // --- macOS Diagnostics --- @@ -1051,29 +1548,29 @@ import type { } from "../workspaces/macos-diag/types"; export async function macosScanEnvironment(): Promise { - return invokeCommand("macos_scan_environment"); + return invokeCommand("macos_scan_environment"); } export async function macosScanIntuneLogs(): Promise { - return invokeCommand("macos_scan_intune_logs"); + return invokeCommand("macos_scan_intune_logs"); } export async function macosListProfiles(): Promise { - return invokeCommand("macos_list_profiles"); + return invokeCommand("macos_list_profiles"); } export async function macosInspectDefender(): Promise { - return invokeCommand("macos_inspect_defender"); + return invokeCommand("macos_inspect_defender"); } export async function macosListPackages(): Promise { - return invokeCommand("macos_list_packages"); + return invokeCommand("macos_list_packages"); } export async function macosGetPackageInfo( packageId: string, ): Promise { - return invokeCommand("macos_get_package_info", { + return invokeCommand("macos_get_package_info", { packageId, }); } @@ -1081,7 +1578,7 @@ export async function macosGetPackageInfo( export async function macosGetPackageFiles( packageId: string, ): Promise { - return invokeCommand("macos_get_package_files", { + return invokeCommand("macos_get_package_files", { packageId, }); } @@ -1092,11 +1589,12 @@ export async function macosQueryUnifiedLog( resultCap: number, ): Promise { const now = new Date(); + const start = new Date(now.getTime() - timeRangeMinutes * 60 * 1000); const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`; const timeRange = { start: fmt(start), end: fmt(now) }; - return invokeCommand("macos_query_unified_log", { + return invokeCommand("macos_query_unified_log", { presetId, timeRange, resultCap, @@ -1110,25 +1608,391 @@ import type { SecureBootAnalysisResult } from "../workspaces/secureboot/types"; export async function analyzeSecureBoot( path?: string | null, ): Promise { - return invokeCommand("analyze_secureboot", { + return invokeCommand("analyze_secureboot", { path: path ?? null, }); } export async function rescanSecureBoot(): Promise { - return invokeCommand("rescan_secureboot", {}); + return invokeCommand("rescan_secureboot", {}); } export async function runSecureBootDetection(): Promise { - return invokeCommand( - "run_secureboot_detection", - {}, - ); + return invokeCommand("run_secureboot_detection", {}); } export async function runSecureBootRemediation(): Promise { - return invokeCommand( - "run_secureboot_remediation", - {}, - ); + return invokeCommand("run_secureboot_remediation", {}); +} + +function decodeSecureBootAnalysisResult( + value: unknown, + commandName: string, +): SecureBootAnalysisResult { + return decodeRecordResponse(value, commandName, { + stage: (field) => typeof field === "string", + dataSource: (field) => typeof field === "string", + scanState: isCommandRecord, + sessions: isCommandRecordArray, + timeline: isCommandRecordArray, + diagnostics: isCommandRecordArray, + scriptResult: isNullableCommandRecord, + }); } + +const decodeSccmCaptureResult: CommandDecoder = ( + value, + commandName, +) => + decodeRecordResponse(value, commandName, { + bundleRoot: (field) => typeof field === "string", + capturedAtUtc: (field) => typeof field === "string", + roles: isStringArray, + sources: isCommandRecordArray, + artifactCount: isFiniteCommandNumber, + retainedBytes: isFiniteCommandNumber, + }); + +const decodeEspSessionEnvelope: CommandDecoder = ( + value, + commandName, +) => + decodeRecordResponse(value, commandName, { + sessionId: (field) => typeof field === "string", + requestId: (field) => typeof field === "string", + sequence: isFiniteCommandNumber, + state: (field) => typeof field === "string", + snapshot: isCommandRecord, + }); +const COMMAND_DECODERS = { + open_log_file: decodeParseResult, + parse_files_batch: decodeParseResults, + list_log_folder: decodeFolderListingResult, + build_timeline_cmd: decodeTimelineBundle, + inspect_evidence_bundle: (value, commandName) => + decodeRecordResponse(value, commandName, { + bundleRootPath: (field) => typeof field === "string", + metadata: isCommandRecord, + manifestContent: (field) => typeof field === "string", + artifacts: isCommandRecordArray, + expectedEvidence: isCommandRecordArray, + observedGaps: isStringArray, + priorityQuestions: isStringArray, + }), + inspect_evidence_artifact: (value, commandName) => + decodeRecordResponse(value, commandName, { + path: (field) => typeof field === "string", + intakeKind: isEvidenceArtifactIntakeKind, + summary: (field) => typeof field === "string", + registrySnapshot: isNullableRegistrySnapshotSummary, + eventLogExport: isNullableEvidenceEventLogExportPreview, + }), + parse_registry_file: (value, commandName) => + decodeRecordResponse(value, commandName, { + keys: isCommandRecordArray, + filePath: (field) => typeof field === "string", + fileSize: isFiniteCommandNumber, + totalKeys: isFiniteCommandNumber, + totalValues: isFiniteCommandNumber, + parseErrors: isFiniteCommandNumber, + }), + get_known_log_sources: (value, commandName) => + decodeRecordArrayResponse(value, commandName, { + id: (field) => typeof field === "string", + label: (field) => typeof field === "string", + description: (field) => typeof field === "string", + platform: (field) => typeof field === "string", + sourceKind: isLogSourceKind, + source: isLogSourceResponse, + filePatterns: isStringArray, + }), + open_log_folder_aggregate: decodeAggregateParseResult, + start_tail: decodeUnitResponse, + stop_tail: decodeUnitResponse, + pause_tail: decodeUnitResponse, + resume_tail: decodeUnitResponse, + analyze_intune_logs: (value, commandName) => + decodeRecordResponse(value, commandName, { + events: isCommandRecordArray, + downloads: isCommandRecordArray, + summary: isCommandRecord, + diagnostics: isCommandRecordArray, + sourceFile: (field) => typeof field === "string", + sourceFiles: isStringArray, + diagnosticsCoverage: isCommandRecord, + diagnosticsConfidence: isCommandRecord, + repeatedFailures: isCommandRecordArray, + guidRegistry: isCommandRecord, + }), + analyze_sysmon_logs: (value, commandName) => + decodeRecordResponse(value, commandName, { + events: isCommandRecordArray, + summary: isCommandRecord, + config: isCommandRecord, + dashboard: isCommandRecord, + sourcePath: (field) => typeof field === "string", + }), + analyze_dsregcmd: (value, commandName) => + decodeRecordResponse(value, commandName, { + facts: isCommandRecord, + derived: isCommandRecord, + diagnostics: isCommandRecordArray, + policyEvidence: isCommandRecord, + osVersion: isNullableCommandRecord, + proxyEvidence: isNullableCommandRecord, + enrollmentEvidence: isNullableCommandRecord, + activeEvidence: isNullableCommandRecord, + scheduledTaskEvidence: isNullableCommandRecord, + eventLogAnalysis: isNullableCommandRecord, + }), + capture_dsregcmd: (value, commandName) => + decodeRecordResponse(value, commandName, { + input: (field) => typeof field === "string", + bundlePath: isNullableCommandString, + evidenceFilePath: isNullableCommandString, + }), + inspect_path_kind: decodePathKindResponse, + write_text_output_file: decodeUnitResponse, + load_dsregcmd_source: (value, commandName) => + decodeRecordResponse(value, commandName, { + input: (field) => typeof field === "string", + bundlePath: isNullableCommandString, + resolvedPath: isNullableCommandString, + evidenceFilePath: isNullableCommandString, + }), + get_initial_file_paths: decodeStringArrayResponse, + get_initial_workspace: decodeNullableWorkspaceIdResponse, + get_app_elevation_state: (value, commandName) => + decodeRecordResponse(value, commandName, { + platformSupported: (field) => typeof field === "boolean", + isElevated: (field) => typeof field === "boolean", + }), + restart_as_administrator: (value, commandName) => + decodeRecordResponse(value, commandName, { + launched: (field) => typeof field === "boolean", + reason: (field) => typeof field === "string", + }), + get_initial_elevation_restore: (value, commandName) => + decodeNullableRecordResponse(value, commandName, { + schemaVersion: isFiniteCommandNumber, + ticketId: (field) => typeof field === "string", + createdAtMs: isFiniteCommandNumber, + originPid: isFiniteCommandNumber, + workspace: isWorkspaceIdValue, + target: isCommandRecord, + reason: (field) => typeof field === "string", + retryAttempted: (field) => typeof field === "boolean", + }), + get_available_workspaces: decodeWorkspaceIdArrayResponse, + discover_sccm_environment: (value, commandName) => + decodeRecordResponse(value, commandName, { + supported: (field) => typeof field === "boolean", + configmgrVersion: isNullableCommandString, + roles: isCommandRecordArray, + sources: isCommandRecordArray, + issues: isCommandRecordArray, + advancedSources: isCommandRecordArray, + }), + capture_sccm_diagnostics: decodeSccmCaptureResult, + authorize_sccm_advanced_capture: (value, commandName) => + decodeRecordResponse(value, commandName, { + capabilityHandle: (field) => typeof field === "string", + cardId: (field) => typeof field === "string", + cardVersion: (field) => typeof field === "string", + sourceId: (field) => typeof field === "string", + roleScope: (field) => typeof field === "string", + pathClass: (field) => typeof field === "string", + sourceVersion: isNullableCommandString, + }), + capture_sccm_advanced_diagnostics: decodeSccmCaptureResult, + cancel_sccm_advanced_capture: decodeUnitResponse, + reveal_in_file_manager: decodeUnitResponse, + get_update_policy: (value, commandName) => + decodeRecordResponse(value, commandName, { + updateChecksDisabledByPolicy: (field) => typeof field === "boolean", + }), + check_dns_logging_status: (value, commandName) => + decodeRecordResponse(value, commandName, { + dnsServerInstalled: (field) => typeof field === "boolean", + debugLoggingEnabled: (field) => typeof field === "boolean", + logFilePath: isNullableCommandString, + dhcpServerInstalled: (field) => typeof field === "boolean", + }), + enable_dns_debug_logging: decodeStringResponse, + collect_dns_dhcp_from_domain: (value, commandName) => + decodeRecordResponse(value, commandName, { + bundlePath: (field) => typeof field === "string", + servers: isCommandRecordArray, + totalFiles: isFiniteCommandNumber, + totalBytes: isFiniteCommandNumber, + durationMs: isFiniteCommandNumber, + }), + get_file_association_prompt_status: (value, commandName) => + decodeRecordResponse(value, commandName, { + supported: (field) => typeof field === "boolean", + shouldPrompt: (field) => typeof field === "boolean", + isAssociated: (field) => typeof field === "boolean", + }), + associate_log_files_with_app: decodeUnitResponse, + set_file_association_prompt_suppressed: decodeUnitResponse, + get_system_date_time_preferences: (value, commandName) => + decodeRecordResponse(value, commandName, { + datePattern: (field) => typeof field === "string", + timePattern: (field) => typeof field === "string", + amDesignator: isNullableCommandString, + pmDesignator: isNullableCommandString, + }), + collect_diagnostics: (value, commandName) => + decodeRecordResponse(value, commandName, { + bundlePath: (field) => typeof field === "string", + bundleId: (field) => typeof field === "string", + artifactCounts: isCommandRecord, + durationMs: isFiniteCommandNumber, + gaps: isCommandRecordArray, + }), + get_esp_elevation_state: (value, commandName) => + decodeRecordResponse(value, commandName, { + isElevated: (field) => typeof field === "boolean", + restartSupported: (field) => typeof field === "boolean", + restrictedSources: isStringArray, + }), + analyze_esp_evidence: (value, commandName) => + decodeRecordResponse(value, commandName, { + schemaVersion: isFiniteCommandNumber, + scenario: (field) => typeof field === "string", + phase: (field) => typeof field === "string", + generatedAtUtc: (field) => typeof field === "string", + elevation: isCommandRecord, + identity: isCommandRecord, + profile: isNullableCommandRecord, + enrollments: isCommandRecordArray, + sessions: isCommandRecordArray, + workloads: isCommandRecordArray, + installerCorrelations: isCommandRecordArray, + nodeCache: isCommandRecordArray, + registrationEvents: isCommandRecordArray, + deliveryOptimization: isNullableCommandRecord, + hardware: isNullableCommandRecord, + activity: isCommandRecordArray, + findings: isCommandRecordArray, + coverage: isCommandRecordArray, + rawEvidence: isCommandRecordArray, + graph: isNullableCommandRecord, + }), + export_esp_session: decodeUnitResponse, + start_esp_diagnostics_session: decodeEspSessionEnvelope, + get_esp_diagnostics_session: decodeEspSessionEnvelope, + stop_esp_diagnostics_session: decodeUnitResponse, + restart_esp_as_administrator: (value, commandName) => + decodeRecordResponse(value, commandName, { + launched: (field) => typeof field === "boolean", + reason: (field) => typeof field === "string", + }), + graph_fetch_esp_diagnostics: (value, commandName) => + decodeRecordResponse(value, commandName, { + requestId: (field) => typeof field === "string", + requestedAtUtc: (field) => typeof field === "string", + deviceMatch: isCommandRecord, + autopilotIdentity: isCommandRecord, + deploymentProfile: isCommandRecord, + intendedDeploymentProfile: isCommandRecord, + profileAssignments: isCommandRecord, + autopilotEvents: isCommandRecord, + enrollmentConfiguration: isCommandRecord, + apps: isCommandRecord, + policies: isCommandRecord, + scripts: isCommandRecord, + }), + esp_flip_app_installed: (value, commandName) => + decodeRecordResponse(value, commandName, { + appId: (field) => typeof field === "string", + installationState: isFiniteCommandNumber, + backup: isCommandRecord, + }), + esp_restore_app_state: decodeUnitResponse, + graph_cancel_esp_diagnostics: decodeUnitResponse, + graph_reserve_interactive_operation: decodeGraphInteractiveOperationTicket, + graph_authenticate: decodeGraphAuthAttemptResult, + graph_cancel_authentication: decodeBooleanResponse, + graph_request_missing_permissions: decodeGraphPermissionUpgradeResult, + graph_get_auth_status: decodeGraphAuthStatus, + graph_sign_out: decodeUnitResponse, + graph_resolve_guids: (value, commandName) => + decodeRecordResponse(value, commandName, { + resolved: isCommandRecord, + notFound: isStringArray, + errors: isStringArray, + }), + graph_fetch_all_apps: (value, commandName) => + decodeRecordArrayResponse(value, commandName, { + id: (field) => typeof field === "string", + displayName: (field) => typeof field === "string", + publisher: isNullableCommandString, + odataType: isNullableCommandString, + }), + macos_scan_environment: (value, commandName) => + decodeRecordResponse(value, commandName, { + macosVersion: (field) => typeof field === "string", + macosBuild: (field) => typeof field === "string", + fullDiskAccess: (field) => typeof field === "string", + tools: isCommandRecord, + directories: isCommandRecord, + summary: (field) => typeof field === "string", + }), + macos_scan_intune_logs: (value, commandName) => + decodeRecordResponse(value, commandName, { + files: isCommandRecordArray, + scannedDirectories: isStringArray, + totalSizeBytes: isFiniteCommandNumber, + }), + macos_list_profiles: (value, commandName) => + decodeRecordResponse(value, commandName, { + profiles: isCommandRecordArray, + enrollmentStatus: isCommandRecord, + rawOutput: (field) => typeof field === "string", + }), + macos_inspect_defender: (value, commandName) => + decodeRecordResponse(value, commandName, { + health: isNullableCommandRecord, + logFiles: isCommandRecordArray, + diagFiles: isCommandRecordArray, + }), + macos_list_packages: (value, commandName) => + decodeRecordResponse(value, commandName, { + packages: isCommandRecordArray, + totalCount: isFiniteCommandNumber, + microsoftCount: isFiniteCommandNumber, + }), + macos_get_package_info: (value, commandName) => + decodeRecordResponse(value, commandName, { + packageId: (field) => typeof field === "string", + version: (field) => typeof field === "string", + volume: isNullableCommandString, + location: isNullableCommandString, + installTime: isNullableCommandString, + }), + macos_get_package_files: (value, commandName) => + decodeRecordResponse(value, commandName, { + packageId: (field) => typeof field === "string", + files: isStringArray, + fileCount: isFiniteCommandNumber, + }), + macos_query_unified_log: (value, commandName) => + decodeRecordResponse(value, commandName, { + entries: isCommandRecordArray, + totalMatched: isFiniteCommandNumber, + capped: (field) => typeof field === "boolean", + resultCap: isFiniteCommandNumber, + predicateUsed: (field) => typeof field === "string", + timeRange: isNullableCommandRecord, + }), + analyze_secureboot: decodeSecureBootAnalysisResult, + rescan_secureboot: decodeSecureBootAnalysisResult, + run_secureboot_detection: decodeSecureBootAnalysisResult, + run_secureboot_remediation: decodeSecureBootAnalysisResult, +} satisfies Record>; + +type CommandName = keyof typeof COMMAND_DECODERS; +type CommandResponse = ReturnType< + (typeof COMMAND_DECODERS)[Name] +>; diff --git a/src/lib/dsregcmd-source.ts b/src/lib/dsregcmd-source.ts index d252ca4f6..7fac03743 100644 --- a/src/lib/dsregcmd-source.ts +++ b/src/lib/dsregcmd-source.ts @@ -146,7 +146,7 @@ export async function analyzeDsregcmdPath( options: { fallbackToFolder?: boolean } = {} ): Promise { try { - return analyzeDsregcmdSource({ kind: "file", path }); + return await analyzeDsregcmdSource({ kind: "file", path }); } catch (error) { if (options.fallbackToFolder === false) { throw error; @@ -156,7 +156,7 @@ export async function analyzeDsregcmdPath( path, error, }); - return analyzeDsregcmdSource({ kind: "folder", path }); + return await analyzeDsregcmdSource({ kind: "folder", path }); } } diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index c51a96f4f..e41ac15ba 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -1,16 +1,30 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deferred } from "../test-utils/deferred"; import type { FolderEntry, + FolderListingResult, KnownSourceMetadata, + LogEntry, LogSource, ParseResult, } from "../types/log"; -import { useLogStore } from "../stores/log-store"; -import { loadLogSource } from "./log-source"; +import type { EvidenceBundleMetadata } from "../types/evidence"; +import type { RegistryParseResult } from "../types/registry"; +import { useLogStore, setCachedTabSnapshot, clearAllTabSnapshots } from "../stores/log-store"; +import type { TabEntrySnapshot } from "./tab-snapshot-cache"; +import { useUiStore } from "../stores/ui-store"; +import { + loadFilesAsLogSource, + loadLogSource, + loadPathAsLogSource, + loadSelectedLogFile, + switchToTab, +} from "./log-source"; const commands = vi.hoisted(() => ({ getKnownLogSources: vi.fn(), listLogSourceFolder: vi.fn(), + inspectPathKind: vi.fn(), openLogFile: vi.fn(), openLogSourceFile: vi.fn(), parseFilesBatch: vi.fn(), @@ -101,9 +115,14 @@ describe("Device Inventory known-source routing", () => { it("loads the Device Inventory folder through the batch loader without a selected file", async () => { const result = await loadLogSource(deviceInventoryFolder); - expect(result.selectedFilePath).toBeNull(); + expect(result).not.toBeNull(); + expect(result?.selectedFilePath).toBeNull(); expect(commands.listLogSourceFolder).toHaveBeenCalledWith(deviceInventoryFolder); - expect(commands.parseFilesBatch).toHaveBeenCalledWith([folderEntries[0].path]); + expect(commands.parseFilesBatch).toHaveBeenCalledWith( + [folderEntries[0].path], + expect.any(Number), + expect.any(Number), + ); expect(commands.openLogSourceFile).not.toHaveBeenCalled(); }); @@ -112,9 +131,887 @@ describe("Device Inventory known-source routing", () => { async (source) => { const result = await loadLogSource(source); - expect(result.selectedFilePath).toBe(parseResult.filePath); + expect(result).not.toBeNull(); + expect(result?.selectedFilePath).toBe(parseResult.filePath); expect(commands.openLogSourceFile).toHaveBeenCalledWith(source); expect(commands.listLogSourceFolder).not.toHaveBeenCalled(); } ); }); + +function makeEntry(id: number, filePath: string, message: string): LogEntry { + return { + id, + lineNumber: id, + message, + component: "AppEnforce", + timestamp: id, + timestampDisplay: `2026-07-26 12:00:0${id}.000`, + severity: "Info", + thread: null, + threadDisplay: null, + sourceFile: null, + format: "Ccm", + filePath, + timezoneOffset: null, + }; +} + +function snapshotFor(filePath: string, message: string): TabEntrySnapshot { + return { + entries: [makeEntry(1, filePath, message)], + formatDetected: "Ccm", + parserSelection: { + parser: "ccm", + implementation: "ccm", + provenance: "dedicated", + parseQuality: "structured", + recordFraming: "physicalLine", + dateOrder: null, + }, + totalLines: 1, + byteOffset: 0, + selectedSourceFilePath: filePath, + sourceOpenMode: "single-file", + activeColumns: ["severity", "dateTime", "message"], + }; +} +function evidenceBundleMetadata(): EvidenceBundleMetadata { + return { + manifestPath: "C:/Evidence/manifest.json", + notesPath: "C:/Evidence/notes.md", + evidenceRoot: "C:/Evidence", + primaryEntryPoints: ["evidence/ime.log"], + availablePrimaryEntryPoints: ["evidence/ime.log"], + bundleId: "bundle-fixture", + bundleLabel: "Fixture bundle", + createdUtc: "2026-08-18T12:00:00Z", + caseReference: "CASE-018", + summary: "Fixture bundle", + collectorProfile: "quick", + collectorVersion: "1.0.0", + collectedUtc: "2026-08-18T12:00:00Z", + deviceName: "TEST-PC", + primaryUser: "analyst", + platform: "windows", + osVersion: "10.0.26100", + tenant: "contoso", + artifactCounts: { + collected: 1, + missing: 0, + failed: 0, + skipped: 0, + }, + }; +} + + + + +describe("switchToTab", () => { + const fileA = "C:/Windows/CCM/Logs/AppEnforce.log"; + const fileB = "C:/Windows/CCM/Logs/CIAgent.log"; + const folderSource: LogSource = { kind: "folder", path: "C:/Windows/CCM/Logs" }; + + beforeEach(() => { + vi.resetAllMocks(); + useLogStore.getState().clear(); + useUiStore.getState().clearTabs(); + clearAllTabSnapshots(); + commands.listLogSourceFolder.mockResolvedValue({ + sourceKind: "folder", + source: folderSource, + entries: [], + bundleMetadata: null, + }); + commands.stopTail.mockResolvedValue(undefined); + }); + it("propagates registry parse failures from selected-file loads", async () => { + const source: LogSource = { kind: "file", path: fileB }; + const registryResult: ParseResult = { + ...parseResult, + filePath: fileB, + parserSelection: { + ...parseResult.parserSelection, + parser: "registry", + implementation: "registry", + }, + }; + const parseError = new Error("registry fixture is unreadable"); + commands.openLogFile.mockResolvedValueOnce(registryResult); + commands.parseRegistryFile.mockRejectedValueOnce(parseError); + + await expect(loadSelectedLogFile(fileB, source)).rejects.toThrow( + "registry fixture is unreadable", + ); + expect(useLogStore.getState().isLoading).toBe(false); + }); + it("returns null when a stale registry parse rejects", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + const registryResult: ParseResult = { + ...parseResult, + filePath: fileA, + parserSelection: { + ...parseResult.parserSelection, + parser: "registry", + implementation: "registry", + }, + }; + const parseError = new Error("registry fixture is unreadable"); + const pendingRegistry = deferred(); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + commands.openLogFile.mockResolvedValueOnce(registryResult); + commands.parseRegistryFile.mockReturnValueOnce(pendingRegistry.promise); + + const pendingLoad = loadSelectedLogFile(fileA, fileSourceA); + await vi.waitFor(() => { + expect(commands.parseRegistryFile).toHaveBeenCalledWith(fileA); + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + pendingRegistry.reject(parseError); + + await expect(pendingLoad).resolves.toBeNull(); + }); + it("returns null when registry application becomes stale", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + const registryResult: ParseResult = { + ...parseResult, + filePath: fileA, + parserSelection: { + ...parseResult.parserSelection, + parser: "registry", + implementation: "registry", + }, + }; + const registryData: RegistryParseResult = { + keys: [], + filePath: fileA, + fileSize: 1, + totalKeys: 0, + totalValues: 0, + parseErrors: 0, + }; + const pendingRegistry = deferred(); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + commands.openLogFile.mockResolvedValueOnce(registryResult); + commands.parseRegistryFile.mockReturnValueOnce(pendingRegistry.promise); + + const pendingLoad = loadSelectedLogFile(fileA, fileSourceA); + await vi.waitFor(() => { + expect(commands.parseRegistryFile).toHaveBeenCalledWith(fileA); + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + pendingRegistry.resolve(registryData); + + await expect(pendingLoad).resolves.toBeNull(); + }); + + it("restores a cached migrated tab as a standalone file", async () => { + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + activeSource: folderSource, + sourceEntries: folderEntries, + bundleMetadata: evidenceBundleMetadata(), + }); + + await switchToTab(fileB, null); + + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().activeSource).toEqual({ + kind: "file", + path: fileB, + }); + expect(useLogStore.getState().sourceEntries).toEqual([]); + expect(useLogStore.getState().bundleMetadata).toBeNull(); + }); + it("swaps the list to the cached file before folder restore finishes", async () => { + setCachedTabSnapshot(fileA, snapshotFor(fileA, "AppEnforce line")); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + activeSource: { kind: "file", path: fileA }, + sourceOpenMode: "aggregate-folder", + }); + + const listing = deferred(); + commands.listLogSourceFolder.mockReturnValue(listing.promise); + + const pending = switchToTab(fileB, { + sourceKind: "folder", + sourcePath: folderSource.path, + source: folderSource, + }); + + await vi.waitFor(() => { + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().selectedSourceFilePath).toBe(fileB); + expect(useLogStore.getState().entries.map((entry) => entry.message)).toEqual([ + "CIAgent line", + ]); + }); + expect(useLogStore.getState().sourceOpenMode).toBe("single-file"); + + listing.resolve({ + sourceKind: "folder", + source: folderSource, + entries: [], + bundleMetadata: null, + }); + await pending; + expect(commands.openLogFile).not.toHaveBeenCalled(); + }); + + it("restores each cached file when switching back and forth", async () => { + setCachedTabSnapshot(fileA, snapshotFor(fileA, "AppEnforce line")); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + activeSource: folderSource, + }); + const ctx = { + sourceKind: "folder" as const, + sourcePath: folderSource.path, + source: folderSource, + }; + + await switchToTab(fileB, ctx); + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().entries[0]?.message).toBe("CIAgent line"); + + await switchToTab(fileA, ctx); + expect(useLogStore.getState().openFilePath).toBe(fileA); + expect(useLogStore.getState().entries[0]?.message).toBe("AppEnforce line"); + }); + + it("does not apply a stale folder listing after a later tab switch", async () => { + const otherFolder: LogSource = { kind: "folder", path: "C:/Windows/CCM/Logs/Other" }; + const fileC = "C:/Windows/CCM/Logs/Start.log"; + setCachedTabSnapshot(fileA, snapshotFor(fileA, "AppEnforce line")); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + setCachedTabSnapshot(fileC, snapshotFor(fileC, "Start line")); + useLogStore.setState({ + openFilePath: fileC, + selectedSourceFilePath: fileC, + entries: snapshotFor(fileC, "Start line").entries, + activeSource: { kind: "folder", path: "C:/Windows/CCM/Logs/Start" }, + }); + const firstListing = deferred(); + commands.listLogSourceFolder.mockReturnValueOnce(firstListing.promise); + commands.listLogSourceFolder.mockResolvedValueOnce({ + sourceKind: "folder", + source: otherFolder, + entries: [ + { + name: "CIAgent.log", + path: fileB, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + bundleMetadata: null, + }); + + const first = switchToTab(fileA, { + sourceKind: "folder", + sourcePath: folderSource.path, + source: folderSource, + }); + const second = switchToTab(fileB, { + sourceKind: "folder", + sourcePath: otherFolder.path, + source: otherFolder, + }); + + await second; + expect(useLogStore.getState().activeSource).toEqual(otherFolder); + + firstListing.resolve({ + sourceKind: "folder", + source: folderSource, + entries: [ + { + name: "AppEnforce.log", + path: fileA, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + bundleMetadata: null, + }); + await first; + expect(useLogStore.getState().activeSource).toEqual(otherFolder); + expect(useLogStore.getState().sourceEntries.map((entry) => entry.path)).toEqual([fileB]); + }); + + it("discards a folder restore when switching to a cached standalone file", async () => { + const fileC = "C:/Windows/CCM/Logs/Start.log"; + const fileSource: LogSource = { kind: "file", path: fileB }; + setCachedTabSnapshot(fileA, snapshotFor(fileA, "AppEnforce line")); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + setCachedTabSnapshot(fileC, snapshotFor(fileC, "Start line")); + useLogStore.setState({ + openFilePath: fileC, + selectedSourceFilePath: fileC, + entries: snapshotFor(fileC, "Start line").entries, + activeSource: { kind: "file", path: fileC }, + }); + + const staleListing = deferred(); + commands.listLogSourceFolder.mockReturnValueOnce(staleListing.promise); + + const folderSwitch = switchToTab(fileA, { + sourceKind: "folder", + sourcePath: folderSource.path, + source: folderSource, + }); + const standaloneSwitch = switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSource, + }); + + await standaloneSwitch; + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().activeSource).toEqual(fileSource); + expect(useLogStore.getState().sourceEntries).toEqual([]); + + staleListing.resolve({ + sourceKind: "folder", + source: folderSource, + entries: [ + { + name: "AppEnforce.log", + path: fileA, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + bundleMetadata: null, + }); + await folderSwitch; + + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().activeSource).toEqual(fileSource); + expect(useLogStore.getState().sourceEntries).toEqual([]); + expect(useLogStore.getState().bundleMetadata).toBeNull(); + }); + it("does not apply a stale cache-miss file load after a later tab switch", async () => { + const fileC = "C:/Windows/CCM/Logs/Start.log"; + const fileSource: LogSource = { kind: "file", path: fileB }; + const fileASnapshot = snapshotFor(fileA, "AppEnforce line"); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + useLogStore.setState({ + openFilePath: fileC, + selectedSourceFilePath: fileC, + entries: snapshotFor(fileC, "Start line").entries, + activeSource: { kind: "file", path: fileC }, + }); + + const listing = deferred(); + const parsed = deferred(); + commands.listLogSourceFolder.mockReturnValueOnce(listing.promise); + commands.openLogFile.mockReturnValueOnce(parsed.promise); + + const folderSwitch = switchToTab(fileA, { + sourceKind: "folder", + sourcePath: folderSource.path, + source: folderSource, + }); + + listing.resolve({ + sourceKind: "folder", + source: folderSource, + entries: [ + { + name: "AppEnforce.log", + path: fileA, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + bundleMetadata: null, + }); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(fileA); + }); + + const standaloneSwitch = switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSource, + }); + await standaloneSwitch; + expect(useLogStore.getState().openFilePath).toBe(fileB); + + parsed.resolve({ + ...parseResult, + filePath: fileA, + entries: fileASnapshot.entries, + parserSelection: fileASnapshot.parserSelection ?? parseResult.parserSelection, + }); + await folderSwitch; + + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().entries[0]?.message).toBe("CIAgent line"); + expect(useLogStore.getState().activeSource).toEqual(fileSource); + }); + it("clears folder context after an uncached standalone switch", async () => { + const fileSourceB: LogSource = { kind: "file", path: fileB }; + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + activeSource: folderSource, + sourceEntries: folderEntries, + bundleMetadata: evidenceBundleMetadata(), + }); + commands.openLogFile.mockResolvedValueOnce({ + ...parseResult, + filePath: fileB, + entries: [makeEntry(2, fileB, "CIAgent line")], + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().activeSource).toEqual(fileSourceB); + expect(useLogStore.getState().sourceEntries).toEqual([]); + expect(useLogStore.getState().bundleMetadata).toBeNull(); + }); + it("returns null when a selected-file load becomes stale", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + + const staleResult = deferred(); + commands.openLogFile.mockReturnValueOnce(staleResult.promise); + + const pendingLoad = loadSelectedLogFile(fileA, fileSourceA); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(fileA); + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + + staleResult.resolve({ + ...parseResult, + filePath: fileA, + entries: [makeEntry(1, fileA, "AppEnforce line")], + }); + + await expect(pendingLoad).resolves.toBeNull(); + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().activeSource).toEqual(fileSourceB); + }); + it("returns null when a stale selected-file load rejects", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + + const staleResult = deferred(); + commands.openLogFile.mockReturnValueOnce(staleResult.promise); + + const pendingLoad = loadSelectedLogFile(fileA, fileSourceA); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(fileA); + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + + staleResult.reject(new Error("selected file failed")); + + await expect(pendingLoad).resolves.toBeNull(); + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().activeSource).toEqual(fileSourceB); + }); + it("invalidates a pending switch when reselecting the displayed tab", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + const fileASnapshot = snapshotFor(fileA, "AppEnforce line"); + setCachedTabSnapshot(fileA, fileASnapshot); + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: fileASnapshot.entries, + activeSource: fileSourceA, + }); + + const parsed = deferred(); + commands.openLogFile.mockReturnValueOnce(parsed.promise); + + const pendingSwitch = switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(fileB); + }); + + await switchToTab(fileA, { + sourceKind: "file", + sourcePath: fileA, + source: fileSourceA, + }); + expect(useLogStore.getState().openFilePath).toBe(fileA); + expect(useLogStore.getState().entries[0]?.message).toBe("AppEnforce line"); + + parsed.resolve({ + ...parseResult, + filePath: fileB, + entries: [makeEntry(2, fileB, "CIAgent line")], + }); + await pendingSwitch; + + expect(useLogStore.getState().openFilePath).toBe(fileA); + expect(useLogStore.getState().entries[0]?.message).toBe("AppEnforce line"); + expect(useLogStore.getState().activeSource).toEqual(fileSourceA); + expect(useLogStore.getState().isLoading).toBe(false); + }); + it("invalidates a pending tab switch when opening a new source", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + const staleResult = deferred(); + const sourceResult: ParseResult = { + ...parseResult, + filePath: fileA, + entries: [makeEntry(1, fileA, "AppEnforce line")], + }; + + commands.openLogFile.mockReturnValueOnce(staleResult.promise); + commands.openLogSourceFile.mockResolvedValueOnce(sourceResult); + + const staleSwitch = switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(fileB); + }); + + await loadLogSource(fileSourceA); + + staleResult.resolve({ + ...parseResult, + filePath: fileB, + entries: [makeEntry(2, fileB, "CIAgent line")], + }); + await staleSwitch; + + expect(useLogStore.getState().openFilePath).toBe(fileA); + expect(useLogStore.getState().entries[0]?.message).toBe("AppEnforce line"); + expect(useLogStore.getState().activeSource).toEqual(fileSourceA); + }); + it("clears stale folder progress when switching tabs", async () => { + const fileSourceB: LogSource = { kind: "file", path: fileB }; + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + useLogStore.setState({ + openFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + folderLoadProgress: 0.5, + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + + expect(useLogStore.getState().folderLoadProgress).toBeNull(); + }); + it("ignores a stale source load after a later tab switch", async () => { + const fileSourceA: LogSource = { kind: "file", path: fileA }; + const fileSourceB: LogSource = { kind: "file", path: fileB }; + const fileBSnapshot = snapshotFor(fileB, "CIAgent line"); + setCachedTabSnapshot(fileB, fileBSnapshot); + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + activeSource: fileSourceA, + }); + + const sourceResult = deferred(); + commands.openLogSourceFile.mockReturnValueOnce(sourceResult.promise); + + const pendingLoad = loadLogSource(fileSourceA); + await vi.waitFor(() => { + expect(commands.openLogSourceFile).toHaveBeenCalledWith(fileSourceA); + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().entries[0]?.message).toBe("CIAgent line"); + + sourceResult.resolve({ + ...parseResult, + filePath: fileA, + entries: [makeEntry(1, fileA, "AppEnforce line")], + }); + await expect(pendingLoad).resolves.toBeNull(); + + expect(useLogStore.getState().openFilePath).toBe(fileB); + expect(useLogStore.getState().entries[0]?.message).toBe("CIAgent line"); + expect(useLogStore.getState().activeSource).toEqual(fileSourceB); + }); + it("ignores stale loads from overlapping migrated-tab switches", async () => { + const fileC = "C:/Windows/CCM/Logs/Start.log"; + const fileSourceA: LogSource = { kind: "file", path: fileA }; + useLogStore.setState({ + openFilePath: fileA, + selectedSourceFilePath: fileA, + entries: snapshotFor(fileA, "AppEnforce line").entries, + activeSource: fileSourceA, + sourceEntries: folderEntries, + bundleMetadata: evidenceBundleMetadata(), + }); + + const staleResult = deferred(); + commands.openLogFile + .mockReturnValueOnce(staleResult.promise) + .mockResolvedValueOnce({ + ...parseResult, + filePath: fileC, + entries: [makeEntry(2, fileC, "Start line")], + }); + + const staleSwitch = switchToTab(fileB, null); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(fileB); + }); + + await switchToTab(fileC, null); + expect(useLogStore.getState().sourceEntries).toEqual([]); + expect(useLogStore.getState().bundleMetadata).toBeNull(); + expect(useLogStore.getState().openFilePath).toBe(fileC); + expect(useLogStore.getState().entries[0]?.message).toBe("Start line"); + + staleResult.resolve({ + ...parseResult, + filePath: fileB, + entries: [makeEntry(2, fileB, "CIAgent line")], + }); + await staleSwitch; + + expect(useLogStore.getState().openFilePath).toBe(fileC); + expect(useLogStore.getState().entries[0]?.message).toBe("Start line"); + expect(useLogStore.getState().activeSource).toEqual({ + kind: "file", + path: fileC, + }); + }); +}); + +describe("source loading progress ownership", () => { + const folderSource: LogSource = { kind: "folder", path: "C:/Windows/CCM/Logs" }; + const sourceEntries: FolderEntry[] = [ + { + name: "AppEnforce.log", + path: "C:/Windows/CCM/Logs/AppEnforce.log", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ]; + + beforeEach(() => { + vi.resetAllMocks(); + useLogStore.getState().clear(); + useUiStore.getState().clearTabs(); + clearAllTabSnapshots(); + commands.stopTail.mockResolvedValue(undefined); + commands.listLogSourceFolder.mockResolvedValue({ + sourceKind: "folder", + source: folderSource, + entries: sourceEntries, + bundleMetadata: null, + }); + }); + + it("clears progress when a progressive source load fails", async () => { + commands.parseFilesBatch.mockRejectedValueOnce(new Error("batch failed")); + + await expect(loadLogSource(folderSource)).rejects.toThrow("batch failed"); + + expect(useLogStore.getState().folderLoadProgress).toBeNull(); + expect(useLogStore.getState().sourceStatus.kind).toBe("error"); + }); + it("returns null when selected-file recovery becomes stale", async () => { + const selectedPath = sourceEntries[0].path; + const currentPath = "C:/Windows/CCM/Logs/Current.log"; + const pendingOpenLogFile = deferred(); + commands.openLogFile.mockReturnValueOnce(pendingOpenLogFile.promise); + const staleLoad = loadLogSource(folderSource, { + selectedFilePath: selectedPath, + }); + await vi.waitFor(() => { + expect(commands.openLogFile).toHaveBeenCalledWith(selectedPath); + }); + + commands.openLogSourceFile.mockResolvedValueOnce({ + ...parseResult, + filePath: currentPath, + }); + await loadLogSource({ kind: "file", path: currentPath }); + pendingOpenLogFile.reject(new Error("selected file failed")); + + await expect(staleLoad).resolves.toBeNull(); + }); + + it("ignores a path probe superseded by a newer source load", async () => { + useLogStore.setState({ + folderLoadProgress: 0.5, + folderLoadRequestId: 123, + }); + const pathKind = deferred<"file" | "folder" | "unknown">(); + commands.inspectPathKind.mockReturnValueOnce(pathKind.promise); + const stalePathLoad = loadPathAsLogSource( + "C:/Windows/CCM/Logs/Stale.log", + ); + expect(useLogStore.getState().folderLoadProgress).toBeNull(); + expect(useLogStore.getState().folderLoadRequestId).toBeNull(); + await vi.waitFor(() => { + expect(commands.inspectPathKind).toHaveBeenCalledWith( + "C:/Windows/CCM/Logs/Stale.log", + ); + }); + + commands.openLogSourceFile.mockResolvedValueOnce({ + ...parseResult, + filePath: "C:/Windows/CCM/Logs/Current.log", + }); + await loadLogSource({ + kind: "file", + path: "C:/Windows/CCM/Logs/Current.log", + }); + + pathKind.resolve("file"); + + await expect(stalePathLoad).resolves.toBeNull(); + expect(commands.openLogSourceFile).toHaveBeenCalledTimes(1); + }); + + it("returns null when a progressive folder load is superseded", async () => { + const pendingBatch = deferred(); + commands.parseFilesBatch.mockReturnValueOnce(pendingBatch.promise); + + const staleFolderLoad = loadLogSource(folderSource); + await vi.waitFor(() => { + expect(commands.parseFilesBatch).toHaveBeenCalledWith( + [sourceEntries[0].path], + expect.any(Number), + expect.any(Number), + ); + }); + + commands.openLogSourceFile.mockResolvedValueOnce({ + ...parseResult, + filePath: "C:/Windows/CCM/Logs/Current.log", + }); + await loadLogSource({ + kind: "file", + path: "C:/Windows/CCM/Logs/Current.log", + }); + + pendingBatch.resolve([]); + await expect(staleFolderLoad).resolves.toBeNull(); + }); + + it("falls back to the folder lane after a current file load fails", async () => { + commands.inspectPathKind.mockResolvedValue("file"); + commands.openLogSourceFile.mockRejectedValueOnce(new Error("is a directory")); + commands.parseFilesBatch.mockResolvedValueOnce([]); + + const result = await loadPathAsLogSource("C:/Windows/CCM/Logs"); + + expect(result?.source).toEqual(folderSource); + expect(commands.listLogSourceFolder).toHaveBeenCalledWith(folderSource); + }); + + it("clears prior progress before starting a multi-file load", async () => { + const stopTailRequest = deferred(); + useLogStore.setState({ + openFilePath: "C:/Windows/CCM/Logs/Current.log", + folderLoadProgress: 0.5, + }); + commands.stopTail.mockReturnValueOnce(stopTailRequest.promise); + commands.parseFilesBatch.mockResolvedValueOnce([]); + + const pendingLoad = loadFilesAsLogSource([ + "C:/Windows/CCM/Logs/AppEnforce.log", + "C:/Windows/CCM/Logs/CIAgent.log", + ]); + await vi.waitFor(() => { + expect(commands.stopTail).toHaveBeenCalledWith( + "C:/Windows/CCM/Logs/Current.log", + ); + }); + + expect(useLogStore.getState().folderLoadProgress).toBeNull(); + + stopTailRequest.resolve(); + await pendingLoad; + }); + it("returns false when a multi-file load is superseded", async () => { + const pendingBatch = deferred(); + const currentPath = "C:/Windows/CCM/Logs/Current.log"; + commands.parseFilesBatch.mockReturnValueOnce(pendingBatch.promise); + + const staleLoad = loadFilesAsLogSource([ + "C:/Windows/CCM/Logs/AppEnforce.log", + "C:/Windows/CCM/Logs/CIAgent.log", + ]); + await vi.waitFor(() => { + expect(commands.parseFilesBatch).toHaveBeenCalled(); + }); + + commands.openLogSourceFile.mockResolvedValueOnce({ + ...parseResult, + filePath: currentPath, + }); + await loadLogSource({ kind: "file", path: currentPath }); + + pendingBatch.resolve([]); + await expect(staleLoad).resolves.toBe(false); + }); +}); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index fe991ff20..2abc44757 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -25,6 +25,7 @@ import type { LogSource, ParseResult, } from "../types/log"; +import type { RegistryParseResult } from "../types/registry"; function buildTabSourceContext(source: LogSource): TabSourceContext { return { @@ -60,6 +61,11 @@ const KNOWN_SOURCE_BY_PRESET_MENU_ID: Record = { }; const KNOWN_SOURCE_BY_MENU_ID: Record = {}; +let tabSwitchGeneration = 0; + +function isCurrentTabSwitch(generation: number): boolean { + return generation === tabSwitchGeneration; +} export interface KnownSourceCatalogActionIds { sourceId?: string | null; @@ -159,12 +165,24 @@ async function stopCurrentTailIfNeeded(nextFilePath: string | null): Promise { + result: ParseResult, + switchGeneration: number, +): Promise { + if (!isCurrentTabSwitch(switchGeneration)) return false; const state = useLogStore.getState(); - // Registry files use a dedicated viewer — load structured data instead of log entries if (result.parserSelection?.parser === "registry") { + let registryData: RegistryParseResult; + try { + registryData = await parseRegistryFile(selectedFilePath); + } catch (err) { + if (!isCurrentTabSwitch(switchGeneration)) return false; + console.error("[log-source] failed to load registry file", err); + throw err; + } + const { setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); + if (!isCurrentTabSwitch(switchGeneration)) return false; + state.setActiveSource(source); state.setSelectedSourceFilePath(selectedFilePath); state.setSourceOpenMode("single-file"); @@ -177,7 +195,6 @@ async function applyParseResultToStore( message: `Loaded ${getBaseName(selectedFilePath)}.`, }); - // Cache a minimal snapshot so tab switching works setCachedTabSnapshot(selectedFilePath, { entries: [], formatDetected: result.formatDetected, @@ -191,18 +208,9 @@ async function applyParseResultToStore( const fileName = selectedFilePath.split(/[\\/]/).pop() ?? selectedFilePath; useUiStore.getState().openTab(selectedFilePath, fileName, buildTabSourceContext(source), "registry"); - - // Load registry data asynchronously — the RegistryViewer component will pick it up - try { - const { setCachedRegistry } = await import("../stores/registry-store"); - const regData = await parseRegistryFile(selectedFilePath); - setCachedRegistry(selectedFilePath, regData); - const { useRegistryStore } = await import("../stores/registry-store"); - useRegistryStore.getState().setRegistryData(regData); - } catch (err) { - console.error("[log-source] failed to load registry file", err); - } - return; + setCachedRegistry(selectedFilePath, registryData); + useRegistryStore.getState().setRegistryData(registryData); + return true; } state.setActiveSource(source); @@ -238,6 +246,7 @@ async function applyParseResultToStore( // Open (or switch to) a tab for the loaded file const fileName = selectedFilePath.split(/[\\/]/).pop() ?? selectedFilePath; useUiStore.getState().openTab(selectedFilePath, fileName, buildTabSourceContext(source)); + return true; } function clearSelectedFileState(source: LogSource, entries: FolderEntry[]): void { @@ -258,8 +267,10 @@ function clearSelectedFileState(source: LogSource, entries: FolderEntry[]): void */ async function loadFolderProgressive( source: LogSource, - folderEntries: FolderEntry[] + folderEntries: FolderEntry[], + loadGeneration: number, ): Promise { + if (!isCurrentTabSwitch(loadGeneration)) return; const state = useLogStore.getState(); const fileEntries = folderEntries.filter((e) => !e.isDir); const folderPath = getLogSourcePath(source) ?? "folder"; @@ -282,6 +293,7 @@ async function loadFolderProgressive( } // Show loading overlay with progress tracking + state.setFolderLoadRequestId(loadGeneration); const totalFiles = fileEntries.length; state.setFolderLoadProgress({ current: 0, total: totalFiles, currentFile: "" }); state.setSourceStatus({ @@ -312,9 +324,11 @@ async function loadFolderProgressive( // by real-time "parse-progress" events from Rust) before we kick off // the next batch IPC call. await new Promise((r) => setTimeout(r, 0)); + if (!isCurrentTabSwitch(loadGeneration)) return; const batchStart = performance.now(); - const batchResults = await parseFilesBatch(batch); + const batchResults = await parseFilesBatch(batch, loadGeneration, offset); + if (!isCurrentTabSwitch(loadGeneration)) return; const batchMs = Math.round(performance.now() - batchStart); console.info(`[log-source] batch ${batchIndex}/${totalBatches} — completed ${batchResults.length} files in ${batchMs} ms`); @@ -328,6 +342,7 @@ async function loadFolderProgressive( // Yield so the "Finalizing..." progress text renders before the heavy // in-memory assembly work below. await new Promise((r) => setTimeout(r, 0)); + if (!isCurrentTabSwitch(loadGeneration)) return; // Cache each file's entries for instant tab switching for (const result of allResults) { @@ -372,6 +387,7 @@ async function loadFolderProgressive( } } + if (!isCurrentTabSwitch(loadGeneration)) return; // Apply the final aggregate state state.setActiveSource(source); state.setSourceEntries(folderEntries); @@ -417,8 +433,12 @@ async function recoverFromSelectedFileLoadFailure( source: LogSource, entries: FolderEntry[], selectedFilePath: string, - error: unknown -): Promise { + error: unknown, + loadGeneration: number, +): Promise { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } const state = useLogStore.getState(); const { kind, message, accessDenied } = classifySourceError(error); @@ -428,7 +448,13 @@ async function recoverFromSelectedFileLoadFailure( error, }); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(null); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } clearSelectedFileState(source, entries); state.setSourceStatus({ @@ -499,10 +525,10 @@ export async function refreshCurrentLogSource(trigger: string): Promise selectedFilePath: context.selectedFilePath, }); - await loadLogSource(context.source, { + const result = await loadLogSource(context.source, { selectedFilePath: context.selectedFilePath, }); - return true; + return result !== null; } export async function refreshKnownLogSources(): Promise { console.info("[log-source] refreshing known source metadata"); @@ -552,10 +578,13 @@ export async function getKnownSourceMetadataById( } export async function loadSelectedLogFile( filePath: string, - source: LogSource -): Promise { + source: LogSource, + switchGeneration?: number, +): Promise { + const loadGeneration = switchGeneration ?? ++tabSwitchGeneration; + if (switchGeneration !== undefined && !isCurrentTabSwitch(switchGeneration)) return null; const state = useLogStore.getState(); - + state.setFolderLoadProgress(null); // Check cache first — if the file was already parsed (e.g., during folder // batch load), skip the IPC call entirely and apply from cache. const cached = getCachedTabSnapshot(filePath); @@ -563,6 +592,18 @@ export async function loadSelectedLogFile( // Registry files from cache — load via the registry pipeline if (cached.parserSelection?.parser === "registry") { console.info("[log-source] loadSelectedLogFile registry from cache", { filePath }); + + const { getCachedRegistry, setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); + if (!isCurrentTabSwitch(loadGeneration)) return null; + + let regData = getCachedRegistry(filePath); + if (!regData) { + regData = await parseRegistryFile(filePath); + if (!isCurrentTabSwitch(loadGeneration)) return null; + setCachedRegistry(filePath, regData); + } + if (!isCurrentTabSwitch(loadGeneration)) return null; + state.setSelectedSourceFilePath(filePath); state.setSourceOpenMode("single-file"); state.setEntries([]); @@ -574,14 +615,6 @@ export async function loadSelectedLogFile( }); const fileName = filePath.split(/[\\/]/).pop() ?? filePath; useUiStore.getState().openTab(filePath, fileName, buildTabSourceContext(source), "registry"); - - // Load registry data - const { getCachedRegistry, setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); - let regData = getCachedRegistry(filePath); - if (!regData) { - regData = await parseRegistryFile(filePath); - setCachedRegistry(filePath, regData); - } useRegistryStore.getState().setRegistryData(regData); return { @@ -635,19 +668,37 @@ export async function loadSelectedLogFile( filePath, }); + if (!isCurrentTabSwitch(loadGeneration)) return null; state.setLoading(true); state.setSourceStatus({ kind: "loading", message: `Loading ${getBaseName(filePath)}...`, }); - await stopCurrentTailIfNeeded(filePath); try { - const result = await openLogFile(filePath); - await applyParseResultToStore(source, result.filePath, result); - return result; + if (!isCurrentTabSwitch(loadGeneration)) return null; + await stopCurrentTailIfNeeded(filePath); + if (!isCurrentTabSwitch(loadGeneration)) return null; + + let result: ParseResult; + try { + result = await openLogFile(filePath); + } catch (error) { + if (!isCurrentTabSwitch(loadGeneration)) return null; + throw error; + } + if (!isCurrentTabSwitch(loadGeneration)) return null; + const applied = await applyParseResultToStore( + source, + result.filePath, + result, + loadGeneration, + ); + return applied ? result : null; } finally { - state.setLoading(false); + if (isCurrentTabSwitch(loadGeneration)) { + state.setLoading(false); + } } } @@ -662,8 +713,12 @@ export async function switchToTab( ): Promise { const logState = useLogStore.getState(); const currentPath = logState.openFilePath; + const generation = ++tabSwitchGeneration; + logState.setLoading(false); + logState.setFolderLoadProgress(null); - // Already showing this file — nothing to do + // Already showing this file — invalidate any older pending switch and stop + // its loading indicator. if (currentPath === filePath) return; // ── Registry tab: restore from registry cache ────────────────────── @@ -678,7 +733,14 @@ export async function switchToTab( // Restore sidebar context if (sourceContext && sourceContext.sourceKind !== "file") { - await restoreFolderContext(logState, sourceContext); + if ( + !(await restoreFolderContext( + sourceContext, + generation, + )) + ) { + return; + } } else if (sourceContext?.sourceKind === "file") { logState.setActiveSource(sourceContext.source); logState.setSourceEntries([]); @@ -687,11 +749,13 @@ export async function switchToTab( // Restore registry data from cache (or reload) const { getCachedRegistry, setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); + if (!isCurrentTabSwitch(generation)) return; const cachedReg = getCachedRegistry(filePath); if (cachedReg) { useRegistryStore.getState().setRegistryData(cachedReg); } else { const regData = await parseRegistryFile(filePath); + if (!isCurrentTabSwitch(generation)) return; setCachedRegistry(filePath, regData); useRegistryStore.getState().setRegistryData(regData); } @@ -704,20 +768,21 @@ export async function switchToTab( if (cached) { console.info("[log-source] tab switch from cache (instant)", { filePath }); - // Restore sidebar folder context if switching between sources - if (sourceContext && sourceContext.sourceKind !== "file") { - await restoreFolderContext(logState, sourceContext); - } else if (sourceContext?.sourceKind === "file") { - // Standalone file — clear folder sidebar state - logState.setActiveSource(sourceContext.source); + const standaloneSource = + sourceContext?.sourceKind === "file" + ? sourceContext.source + : sourceContext === null + ? { kind: "file" as const, path: filePath } + : null; + if (standaloneSource) { + logState.setActiveSource(standaloneSource); logState.setSourceEntries([]); logState.setBundleMetadata(null); } - // Swap parsed entries into the store — this is the fast path logState.setEntries(cached.entries); logState.setSelectedSourceFilePath(cached.selectedSourceFilePath); - logState.setSourceOpenMode(cached.sourceOpenMode); + logState.setOpenFilePath(filePath); logState.setFormatDetected(cached.formatDetected); logState.setParserSelection(cached.parserSelection); logState.setTotalLines(cached.totalLines); @@ -725,20 +790,38 @@ export async function switchToTab( logState.setActiveColumns(cached.activeColumns); useUiStore.getState().resetColumnWidths(); logState.setAggregateFiles([]); + logState.setSourceOpenMode(cached.sourceOpenMode); logState.selectEntry(null); logState.setSourceStatus({ kind: "loaded", message: `Loaded ${getBaseName(filePath)}.`, }); + + if (sourceContext && sourceContext.sourceKind !== "file") { + try { + const restored = await restoreFolderContext( + sourceContext, + generation, + ); + if (!restored) return; + } catch (error) { + console.warn("[log-source] folder context restore failed after tab switch", { + filePath, + error, + }); + } + } return; } - - // ── Cache miss — fall back to IPC load ───────────────────────────── - console.info("[log-source] tab switch cache miss, loading from disk", { filePath }); - - // No source context (legacy tab) — fall back to the old path + // Migrated tabs retain a file path but no source context. Resolve the path + // through the same lane selector, then use the generation-aware file loader. if (!sourceContext) { - await loadPathAsLogSource(filePath); + const legacySource = await resolveSourceForPath(filePath, false, false); + if (!isCurrentTabSwitch(generation)) return; + await loadSelectedLogFile(filePath, legacySource, generation); + if (!isCurrentTabSwitch(generation)) return; + logState.setSourceEntries([]); + logState.setBundleMetadata(null); return; } @@ -746,20 +829,33 @@ export async function switchToTab( if (sourceContext.sourceKind === "file") { // Standalone file — load directly - await loadLogSource(source); + await loadSelectedLogFile(filePath, source, generation); + if (!isCurrentTabSwitch(generation)) return; + logState.setSourceEntries([]); + logState.setBundleMetadata(null); return; } // Folder or known-source tab — restore sidebar then load the file - await restoreFolderContext(logState, sourceContext); - await loadSelectedLogFile(filePath, source); + if ( + !(await restoreFolderContext( + sourceContext, + generation, + )) + ) { + return; + } + await loadSelectedLogFile(filePath, source, generation); } /** Restore the sidebar folder listing if the active source changed. */ async function restoreFolderContext( - logState: ReturnType, - sourceContext: TabSourceContext -): Promise { + sourceContext: TabSourceContext, + restoreGeneration: number, +): Promise { + if (!isCurrentTabSwitch(restoreGeneration)) return false; + const logState = useLogStore.getState(); + const { source } = sourceContext; const currentSource = logState.activeSource; const sourceChanged = @@ -767,36 +863,47 @@ async function restoreFolderContext( currentSource.kind !== source.kind || getLogSourcePath(currentSource) !== getLogSourcePath(source); - if (sourceChanged) { - console.info("[log-source] restoring folder context", { - sourceKind: source.kind, - sourcePath: getLogSourcePath(source), - }); - - const listing = await listLogSourceFolder(source); - logState.setActiveSource(source); - logState.setSourceEntries(listing.entries); - logState.setBundleMetadata(listing.bundleMetadata ?? null); + if (!sourceChanged) { + return true; } + + console.info("[log-source] restoring folder context", { + sourceKind: source.kind, + sourcePath: getLogSourcePath(source), + }); + + const listing = await listLogSourceFolder(source); + if (!isCurrentTabSwitch(restoreGeneration)) return false; + logState.setActiveSource(source); + logState.setSourceEntries(listing.entries); + logState.setBundleMetadata(listing.bundleMetadata ?? null); + return true; } /** * Load multiple files as a merged aggregate view. * Reuses the same batch-parse + merge logic as folder loading. */ -export async function loadFilesAsLogSource(paths: string[]): Promise { - if (paths.length === 0) return; +export async function loadFilesAsLogSource(paths: string[]): Promise { + if (paths.length === 0) return true; // Single file — use normal single-file flow if (paths.length === 1) { - await loadPathAsLogSource(paths[0], { fallbackToFolder: false }); - return; + const result = await loadPathAsLogSource(paths[0], { + fallbackToFolder: false, + }); + return result !== null; } + const loadGeneration = ++tabSwitchGeneration; const state = useLogStore.getState(); + state.setFolderLoadProgress(null); + state.setFolderLoadRequestId(loadGeneration); // Clean up current state before starting the parse + if (!isCurrentTabSwitch(loadGeneration)) return false; await stopCurrentTailIfNeeded(null); + if (!isCurrentTabSwitch(loadGeneration)) return false; useFilterStore.getState().clearFilter(); state.setLoading(true); @@ -810,7 +917,8 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { const startTime = performance.now(); try { - const results = await parseFilesBatch(paths); + const results = await parseFilesBatch(paths, loadGeneration, 0); + if (!isCurrentTabSwitch(loadGeneration)) return false; const parseMs = Math.round(performance.now() - startTime); // Cache each file for instant tab switching @@ -869,6 +977,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { modifiedUnixMs: 0, })); + if (!isCurrentTabSwitch(loadGeneration)) return false; state.setActiveSource(source); state.setSourceEntries(folderEntries); state.setSelectedSourceFilePath(null); @@ -895,9 +1004,12 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { message: `Loaded ${aggregateFiles.length} files.`, detail: `Parsed in ${parseMs} ms (parallel).`, }); + return true; } finally { - state.setLoading(false); - state.setFolderLoadProgress(null); + if (isCurrentTabSwitch(loadGeneration)) { + state.setLoading(false); + state.setFolderLoadProgress(null); + } } } @@ -980,7 +1092,9 @@ async function resolveSourceForPath( export async function loadPathAsLogSource( path: string, options: LoadPathAsLogSourceOptions = {} -): Promise { +): Promise { + const probeGeneration = ++tabSwitchGeneration; + useLogStore.getState().setFolderLoadProgress(null); const loadOptions: LoadLogSourceOptions = { selectedFilePath: options.selectedFilePath ?? null, }; @@ -990,10 +1104,12 @@ export async function loadPathAsLogSource( options.preferFolder === true, options.fallbackToFolder !== false ); + if (!isCurrentTabSwitch(probeGeneration)) return null; try { - return await loadLogSource(primarySource, loadOptions); + return await loadLogSource(primarySource, loadOptions, probeGeneration); } catch (error) { + if (!isCurrentTabSwitch(probeGeneration)) return null; const allowFolderFallback = options.fallbackToFolder !== false; // Keyed off the lane actually taken, not off `preferFolder`: the kind probe @@ -1003,16 +1119,23 @@ export async function loadPathAsLogSource( throw error; } + if (!isCurrentTabSwitch(probeGeneration)) return null; console.info("[log-source] retrying path as folder source", { path }); - return loadLogSource({ kind: "folder", path }, loadOptions); + return loadLogSource({ kind: "folder", path }, loadOptions, probeGeneration); } } export async function loadLogSource( source: LogSource, - options: LoadLogSourceOptions = {} -): Promise { + options: LoadLogSourceOptions = {}, + existingGeneration?: number, +): Promise { + // A new source load supersedes any pending tab restoration. Path probes pass + // their already-claimed generation through so a current load error can still + // take its documented folder fallback. + const loadGeneration = existingGeneration ?? ++tabSwitchGeneration; const state = useLogStore.getState(); + state.setFolderLoadProgress(null); console.info("[log-source] loading source container", { source, @@ -1027,12 +1150,29 @@ export async function loadLogSource( try { if (source.kind === "file") { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(source.path); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } const result = await openLogSourceFile(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } state.setSourceEntries([]); state.setBundleMetadata(null); - await applyParseResultToStore(source, result.filePath, result); + const applied = await applyParseResultToStore( + source, + result.filePath, + result, + loadGeneration, + ); + if (!applied) { + return null; + } return { source, @@ -1046,14 +1186,26 @@ export async function loadLogSource( if (source.kind === "folder") { const listing = await listLogSourceFolder(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } state.setActiveSource(source); state.setSourceEntries(listing.entries); state.setBundleMetadata(listing.bundleMetadata ?? null); if (!requestedFilePath) { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(null); - await loadFolderProgressive(source, listing.entries); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } + await loadFolderProgressive(source, listing.entries, loadGeneration); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } return { source, @@ -1063,13 +1215,21 @@ export async function loadLogSource( }; } - return recoverOrLoadSelectedFolderFile(source, listing.entries, requestedFilePath); + return recoverOrLoadSelectedFolderFile( + source, + listing.entries, + requestedFilePath, + loadGeneration, + ); } const knownSources = state.knownSources.length > 0 ? state.knownSources : await refreshKnownLogSources(); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } const metadata = knownSources.find((item) => item.id === source.sourceId); @@ -1078,12 +1238,29 @@ export async function loadLogSource( } if (source.pathKind === "file") { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(source.defaultPath); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } const result = await openLogSourceFile(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } state.setSourceEntries([]); state.setBundleMetadata(null); - await applyParseResultToStore(source, result.filePath, result); + const applied = await applyParseResultToStore( + source, + result.filePath, + result, + loadGeneration, + ); + if (!applied) { + return null; + } return { source, @@ -1094,14 +1271,26 @@ export async function loadLogSource( } const listing = await listLogSourceFolder(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } state.setActiveSource(source); state.setSourceEntries(listing.entries); state.setBundleMetadata(listing.bundleMetadata ?? null); if (!requestedFilePath) { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(null); - await loadFolderProgressive(source, listing.entries); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } + await loadFolderProgressive(source, listing.entries, loadGeneration); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } return { source, @@ -1111,14 +1300,23 @@ export async function loadLogSource( }; } - return recoverOrLoadSelectedFolderFile(source, listing.entries, requestedFilePath); + return recoverOrLoadSelectedFolderFile( + source, + listing.entries, + requestedFilePath, + loadGeneration, + ); } catch (error) { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } const { kind, message, accessDenied } = classifySourceError(error); state.setActiveSource(source); state.setSourceEntries([]); state.setBundleMetadata(null); state.clearActiveFile(); + state.setFolderLoadProgress(null); state.setSourceStatus({ kind, message: accessDenied @@ -1148,17 +1346,27 @@ export async function loadLogSource( throw error; } finally { - state.setLoading(false); + if (isCurrentTabSwitch(loadGeneration)) { + state.setLoading(false); + } } } async function recoverOrLoadSelectedFolderFile( source: LogSource, entries: FolderEntry[], - requestedFilePath: string -): Promise { + requestedFilePath: string, + loadGeneration: number, +): Promise { try { - const result = await loadSelectedLogFile(requestedFilePath, source); + const result = await loadSelectedLogFile( + requestedFilePath, + source, + loadGeneration, + ); + if (!result || !isCurrentTabSwitch(loadGeneration)) { + return null; + } return { source, @@ -1167,6 +1375,12 @@ async function recoverOrLoadSelectedFolderFile( parseResult: result, }; } catch (error) { - return recoverFromSelectedFileLoadFailure(source, entries, requestedFilePath, error); + return recoverFromSelectedFileLoadFailure( + source, + entries, + requestedFilePath, + error, + loadGeneration, + ); } } diff --git a/src/lib/session-restore.test.ts b/src/lib/session-restore.test.ts index dee1ef7dd..1e0633b90 100644 --- a/src/lib/session-restore.test.ts +++ b/src/lib/session-restore.test.ts @@ -1,31 +1,42 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { invoke } from "@tauri-apps/api/core"; import { readTextFile } from "@tauri-apps/plugin-fs"; +import { loadFilesAsLogSource, loadPathAsLogSource } from "./log-source"; import { restoreSession } from "./session-restore"; import { useFilterStore } from "../stores/filter-store"; // Keep restore off the real backend/file loaders — we only care that the saved // filter clauses end up in the filter store (issue #193). vi.mock("./log-source", () => ({ - loadPathAsLogSource: vi.fn().mockResolvedValue(undefined), - loadFilesAsLogSource: vi.fn().mockResolvedValue(undefined), + loadPathAsLogSource: vi.fn().mockResolvedValue({}), + loadFilesAsLogSource: vi.fn().mockResolvedValue(true), })); -function sessionJson(clauses: unknown[]): string { +const restoredLoadResult = { + source: { kind: "file", path: "/tmp/app.log" } as const, + entries: [], + selectedFilePath: "/tmp/app.log", + parseResult: null, +}; + +function sessionJson(clauses: unknown[], tabCount = 1): string { + const tabs = Array.from({ length: tabCount }, (_, index) => { + const filePath = index === 0 ? "/tmp/app.log" : `/tmp/app-${index}.log`; + return { + filePath, + fileHash: "abc", + fileSize: 100, + selectedId: null, + scrollPosition: null, + activeColumns: [], + }; + }); + return JSON.stringify({ version: 1, savedAt: "2026-01-01T00:00:00Z", workspace: "log", - tabs: [ - { - filePath: "/tmp/app.log", - fileHash: "abc", - fileSize: 100, - selectedId: null, - scrollPosition: null, - activeColumns: [], - }, - ], + tabs, activeTabIndex: 0, mergedTabState: null, filters: { @@ -41,6 +52,11 @@ function sessionJson(clauses: unknown[]): string { describe("restoreSession filter restore (issue #193)", () => { beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadPathAsLogSource) + .mockReset() + .mockResolvedValue(restoredLoadResult); + vi.mocked(loadFilesAsLogSource).mockReset().mockResolvedValue(true); // compute_file_hash returns a matching hash so the tab is considered valid. vi.mocked(invoke).mockResolvedValue({ hash: "abc", sizeBytes: 100 }); useFilterStore.getState().clearFilter(); @@ -59,6 +75,27 @@ describe("restoreSession filter restore (issue #193)", () => { expect(clauses).toEqual([{ field: "Message", op: "Contains", value: "error" }]); }); + it("does not aggregate after an individual restore is superseded", async () => { + vi.mocked(readTextFile).mockResolvedValue(sessionJson([], 2)); + vi.mocked(loadPathAsLogSource).mockResolvedValueOnce(null); + + await expect(restoreSession("/tmp/session.cmtrace")).resolves.toBeNull(); + expect(loadPathAsLogSource).toHaveBeenCalledTimes(1); + expect(loadFilesAsLogSource).not.toHaveBeenCalled(); + }); + it("aborts when aggregate restore is superseded", async () => { + vi.mocked(readTextFile).mockResolvedValue(sessionJson([], 2)); + vi.mocked(loadPathAsLogSource).mockRejectedValue(new Error("load failed")); + vi.mocked(loadFilesAsLogSource).mockResolvedValue(false); + + await expect(restoreSession("/tmp/session.cmtrace")).resolves.toBeNull(); + expect(loadFilesAsLogSource).toHaveBeenCalledWith([ + "/tmp/app.log", + "/tmp/app-1.log", + ]); + }); + + it("leaves the filter cleared when the session had no clauses", async () => { vi.mocked(readTextFile).mockResolvedValue(sessionJson([])); diff --git a/src/lib/session-restore.ts b/src/lib/session-restore.ts index 78267e3f1..14b324147 100644 --- a/src/lib/session-restore.ts +++ b/src/lib/session-restore.ts @@ -108,13 +108,19 @@ export async function restoreSession(sessionPath: string): Promise t.filePath); const loadedTabsByPath = new Map(); for (const tab of validTabs) { try { - await loadPathAsLogSource(tab.filePath, { fallbackToFolder: false }); + const result = await loadPathAsLogSource(tab.filePath, { fallbackToFolder: false }); + if (result === null) { + console.info("[session] restore superseded by a newer source load"); + return null; + } loadedTabsByPath.set(tab.filePath, tab); } catch (error) { console.warn("[session] failed to load file during restore", { filePath: tab.filePath, error }); @@ -123,7 +129,11 @@ export async function restoreSession(sessionPath: string): Promise 0) { try { - await loadFilesAsLogSource(filePaths); + const aggregateLoadCompleted = await loadFilesAsLogSource(filePaths); + if (!aggregateLoadCompleted) { + console.info("[session] aggregate restore superseded by a newer source load"); + return null; + } // Aggregate load opens one tab per file in the same order. for (const tab of validTabs) loadedTabsByPath.set(tab.filePath, tab); } catch (fallbackError) { diff --git a/src/stores/log-store.ts b/src/stores/log-store.ts index a78c2dbbf..aebab25d4 100644 --- a/src/stores/log-store.ts +++ b/src/stores/log-store.ts @@ -596,6 +596,8 @@ interface LogState { activeColumns: ColumnId[]; /** Folder loading progress (0–1) while progressive loading is active, null otherwise. */ folderLoadProgress: number | null; + /** Request generation that owns parse-progress events for the active batch. */ + folderLoadRequestId: number | null; /** Name of the file currently being parsed during folder loading. */ folderLoadCurrentFile: string | null; /** Total file count in the current folder load. */ @@ -659,6 +661,7 @@ interface LogState { clearFind: () => void; clearActiveFile: () => void; clear: () => void; + setFolderLoadRequestId: (requestId: number | null) => void; setFolderLoadProgress: (progress: { current: number; total: number; @@ -791,6 +794,7 @@ export const useLogStore = create((set, get) => ({ findCurrentIndex: -1, byteOffset: 0, folderLoadProgress: null, + folderLoadRequestId: null, folderLoadCurrentFile: null, folderLoadTotalFiles: null, folderLoadCompletedFiles: null, @@ -1178,6 +1182,11 @@ export const useLogStore = create((set, get) => ({ message: "Ready", }, byteOffset: 0, + folderLoadProgress: null, + folderLoadRequestId: null, + folderLoadCurrentFile: null, + folderLoadTotalFiles: null, + folderLoadCompletedFiles: null, guidNameMap: {}, mergedTabState: null, correlatedEntries: [], @@ -1187,6 +1196,7 @@ export const useLogStore = create((set, get) => ({ findRegexError: null, pendingScrollTarget: null, }), + setFolderLoadRequestId: (requestId) => set({ folderLoadRequestId: requestId }), setFolderLoadProgress: (progress) => set( progress @@ -1201,6 +1211,7 @@ export const useLogStore = create((set, get) => ({ folderLoadCurrentFile: null, folderLoadTotalFiles: null, folderLoadCompletedFiles: null, + folderLoadRequestId: null, } ), setPendingScrollTarget: (target) => set({ pendingScrollTarget: target }), diff --git a/src/stores/timeline-store.test.ts b/src/stores/timeline-store.test.ts index c075d56db..a0bcb780e 100644 --- a/src/stores/timeline-store.test.ts +++ b/src/stores/timeline-store.test.ts @@ -46,6 +46,13 @@ describe("timeline-store", () => { expect(s.bundle?.id).toBe("t1"); expect(s.laneVisibility).toEqual({ 0: true, 1: true }); }); + it("tracks and clears source-open errors", () => { + useTimelineStore.getState().setLoadError("timeline build failed"); + expect(useTimelineStore.getState().loadError).toBe("timeline build failed"); + + useTimelineStore.getState().setBundle(bundle); + expect(useTimelineStore.getState().loadError).toBeNull(); + }); it("solo toggle sets and clears soloSourceIdx", () => { useTimelineStore.getState().setBundle(bundle); diff --git a/src/stores/timeline-store.ts b/src/stores/timeline-store.ts index 68c23b6ff..de7cc2f51 100644 --- a/src/stores/timeline-store.ts +++ b/src/stores/timeline-store.ts @@ -8,6 +8,8 @@ import type { interface TimelineState { bundle: TimelineBundle | null; + timelineGeneration: number; + loadError: string | null; selectedIncidentId: number | null; brushRange: [number, number] | null; laneVisibility: Record; @@ -17,6 +19,7 @@ interface TimelineState { entryCache: Map; setBundle(b: TimelineBundle | null): void; + setLoadError(error: string | null): void; reset(): void; setBrushRange(r: [number, number]): void; clearBrushRange(): void; @@ -35,6 +38,8 @@ const MAX_ENTRY_CACHE = 128; export const useTimelineStore = create((set, get) => ({ bundle: null, + timelineGeneration: 0, + loadError: null, selectedIncidentId: null, brushRange: null, laneVisibility: {}, @@ -47,15 +52,20 @@ export const useTimelineStore = create((set, get) => ({ b?.sources.forEach((s) => { laneVisibility[s.idx] = true; }); - set({ + set((state) => ({ bundle: b, + loadError: null, + timelineGeneration: state.timelineGeneration + 1, selectedIncidentId: null, brushRange: null, laneVisibility, soloSourceIdx: null, bucketCache: new Map(), entryCache: new Map(), - }); + })); + }, + setLoadError(error) { + set({ loadError: error }); }, reset() { diff --git a/src/stores/ui-store.test.ts b/src/stores/ui-store.test.ts index dead355fe..25179a5f4 100644 --- a/src/stores/ui-store.test.ts +++ b/src/stores/ui-store.test.ts @@ -48,6 +48,49 @@ describe("ui-store", () => { }); }); + describe("DNS banner dismissal", () => { + it("persists dismissed paths across rehydration", async () => { + await useUiStore.persist.clearStorage(); + useUiStore.setState({ dismissedDnsBannerPaths: [] }); + + useUiStore.getState().dismissDnsBannerPath("C:/Logs/DnsServer.log"); + + const persistedStorage = localStorage.getItem("cmtraceopen-ui-preferences"); + const persisted = JSON.parse(persistedStorage ?? "{}"); + expect(persisted.state?.dismissedDnsBannerPaths).toEqual([ + "C:/Logs/DnsServer.log", + ]); + + useUiStore.setState({ dismissedDnsBannerPaths: [] }); + if (persistedStorage) { + localStorage.setItem("cmtraceopen-ui-preferences", persistedStorage); + } + await useUiStore.persist.rehydrate(); + + expect(useUiStore.getState().dismissedDnsBannerPaths).toEqual([ + "C:/Logs/DnsServer.log", + ]); + }); + + it("filters invalid dismissed paths during rehydration", async () => { + useUiStore.setState({ dismissedDnsBannerPaths: [] }); + localStorage.setItem( + "cmtraceopen-ui-preferences", + JSON.stringify({ + state: { + dismissedDnsBannerPaths: ["C:/Logs/DnsServer.log", 42, null], + }, + }), + ); + + await useUiStore.persist.rehydrate(); + + expect(useUiStore.getState().dismissedDnsBannerPaths).toEqual([ + "C:/Logs/DnsServer.log", + ]); + }); + }); + describe("persisted preferences", () => { it("finishes hydration when no preferences have been stored yet", async () => { await useUiStore.persist.clearStorage(); diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index b02f080db..709c85dba 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -180,6 +180,7 @@ interface UiState { defaultShowInfoPane: boolean; confirmTabClose: boolean; showUpdateDialog: boolean; + dismissedDnsBannerPaths: string[]; recentSessions: string[]; graphApiEnabled: boolean; graphApiStatus: GraphApiPhase; @@ -249,6 +250,7 @@ interface UiState { setCollectionResult: (result: CollectionResult | null) => void; setShowCollectDiagnosticsDialog: (show: boolean) => void; setShowUpdateDialog: (show: boolean) => void; + dismissDnsBannerPath: (path: string) => void; addRecentSession: (path: string) => void; clearRecentSessions: () => void; setGraphApiEnabled: (enabled: boolean) => void; @@ -271,6 +273,16 @@ const sanitizePersistedUiState = ( delete sanitized.graphApiCapability; delete sanitized.graphApiLastAttempt; + if (sanitized.dismissedDnsBannerPaths !== undefined) { + sanitized.dismissedDnsBannerPaths = Array.isArray( + sanitized.dismissedDnsBannerPaths + ) + ? sanitized.dismissedDnsBannerPaths.filter( + (path): path is string => typeof path === "string" + ) + : []; + } + if (sanitized.logListFontSize !== undefined) { const raw = Number(sanitized.logListFontSize); const base = Number.isFinite(raw) ? raw : DEFAULT_LOG_LIST_FONT_SIZE; @@ -344,6 +356,7 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, + dismissedDnsBannerPaths: [], recentSessions: [], graphApiEnabled: false, graphApiStatus: "disconnected", @@ -639,6 +652,12 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), + dismissDnsBannerPath: (path) => + set((state) => ({ + dismissedDnsBannerPaths: state.dismissedDnsBannerPaths.includes(path) + ? state.dismissedDnsBannerPaths + : [...state.dismissedDnsBannerPaths, path], + })), addRecentSession: (path) => set((state) => { const filtered = state.recentSessions.filter((p) => p !== path); @@ -664,6 +683,7 @@ export const useUiStore = create()( defaultShowInfoPane: state.defaultShowInfoPane, confirmTabClose: state.confirmTabClose, alwaysOnTop: state.alwaysOnTop, + dismissedDnsBannerPaths: state.dismissedDnsBannerPaths, graphApiEnabled: state.graphApiEnabled, recentSessions: state.recentSessions, }), diff --git a/src/test-utils/deferred.ts b/src/test-utils/deferred.ts new file mode 100644 index 000000000..e4b5433c8 --- /dev/null +++ b/src/test-utils/deferred.ts @@ -0,0 +1,24 @@ +export function deferred() { + let resolvePromise: ((value: T) => void) | undefined; + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + return { + promise, + resolve(value: T) { + if (!resolvePromise) { + throw new Error("Deferred promise resolver was not initialized"); + } + resolvePromise(value); + }, + reject(reason: unknown) { + if (!rejectPromise) { + throw new Error("Deferred promise rejecter was not initialized"); + } + rejectPromise(reason); + }, + }; +} diff --git a/src/workspaces/deployment/DeploymentErrorCard.tsx b/src/workspaces/deployment/DeploymentErrorCard.tsx index 1ee957f93..e37440f53 100644 --- a/src/workspaces/deployment/DeploymentErrorCard.tsx +++ b/src/workspaces/deployment/DeploymentErrorCard.tsx @@ -104,7 +104,9 @@ export function DeploymentErrorCard({ appearance="subtle" onClick={() => toggleErrorExpanded(index)} > - {isExpanded ? "Collapse" : `${file.errorLines.length} errors`} + {isExpanded + ? "Collapse" + : `${file.errorLines.length} error${file.errorLines.length === 1 ? "" : "s"}`} )}
diff --git a/src/workspaces/deployment/DeploymentWorkspace.test.tsx b/src/workspaces/deployment/DeploymentWorkspace.test.tsx new file mode 100644 index 000000000..7176fbb04 --- /dev/null +++ b/src/workspaces/deployment/DeploymentWorkspace.test.tsx @@ -0,0 +1,159 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadPathAsLogSource } from "../../lib/log-source"; +import { useLogStore } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { DeploymentWorkspace } from "./DeploymentWorkspace"; +import { + useDeploymentStore, + type DeploymentAnalysisResult, + type DeploymentLogFile, +} from "./deployment-store"; + + +vi.mock("../../lib/log-source", () => ({ + loadPathAsLogSource: vi.fn().mockResolvedValue(null), +})); +function file(overrides: Partial = {}): DeploymentLogFile { + return { + path: "C:\\Windows\\Logs\\Software\\app.log", + fileName: "app.log", + format: "psadt-cmtrace", + outcome: "success", + exitCode: 0, + errorSummary: null, + errorLines: [], + appName: "Contoso App", + appVersion: "1.2.0", + deployType: "Install", + startTime: "2026-01-15T12:00:00", + endTime: "2026-01-15T12:01:00", + ...overrides, + }; +} + +function readyResult(): DeploymentAnalysisResult { + return { + folderPath: "C:\\Windows\\Logs\\Software", + files: [ + file({ + path: "C:\\Windows\\Logs\\Software\\fail.log", + fileName: "fail.log", + format: "psadt-cmtrace", + outcome: "failure", + exitCode: 1603, + appName: "Broken App", + errorSummary: "Installation failed with 1603", + errorLines: [ + { lineNumber: 42, message: "CustomAction failed", severity: "Error" }, + ], + }), + file({ + path: "C:\\Windows\\Logs\\Software\\ok.msi.log", + fileName: "ok.msi.log", + format: "msi-verbose", + outcome: "success", + appName: "Good MSI", + }), + file({ + path: "C:\\Windows\\Logs\\Software\\later.log", + fileName: "later.log", + format: "burn", + outcome: "deferred", + appName: "Deferred Burn", + exitCode: 1618, + }), + file({ + path: "C:\\Windows\\Logs\\Software\\mystery.log", + fileName: "mystery.log", + format: "unknown", + outcome: "unknown", + appName: null, + }), + ], + totalFiles: 4, + succeeded: 1, + failed: 1, + deferred: 1, + unknown: 1, + }; +} + +function seedReady() { + useDeploymentStore.setState({ + phase: "ready", + result: readyResult(), + errorMessage: null, + expandedErrorIndex: null, + }); +} + +afterEach(() => { + cleanup(); + useDeploymentStore.getState().reset(); + useLogStore.getState().setPendingScrollTarget(null); + useUiStore + .getState() + .setActiveView(useUiStore.getInitialState().activeView); +}); + +beforeEach(() => { + useDeploymentStore.getState().reset(); +}); + +describe("DeploymentWorkspace fixtures", () => { + it("DEP-001 shows folder analysis inventory and outcome counts", () => { + seedReady(); + render(); + + expect(screen.getByText("Software Deployment Analysis")).toBeInTheDocument(); + expect(screen.getByText("C:\\Windows\\Logs\\Software")).toBeInTheDocument(); + expect(screen.getByText("1 PSADT")).toBeInTheDocument(); + expect(screen.getByText("1 MSI verbose")).toBeInTheDocument(); + expect(screen.getByText("1 WiX/Burn")).toBeInTheDocument(); + expect(screen.getByText("1 Other")).toBeInTheDocument(); + expect(screen.getByText("4 total")).toBeInTheDocument(); + expect(screen.getByText("1 failed")).toBeInTheDocument(); + expect(screen.getByText("1 succeeded")).toBeInTheDocument(); + expect(screen.getByText("1 deferred")).toBeInTheDocument(); + expect(screen.getByText("1 unknown")).toBeInTheDocument(); + }); + + it("DEP-002 shows failed cards and succeeded/deferred/unclassified tables", () => { + seedReady(); + render(); + + expect(screen.getByText("Failed Deployments")).toBeInTheDocument(); + expect(screen.getByText("Broken App")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open in Log Viewer" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "1 error" })).toBeInTheDocument(); + expect(screen.getByText("Installation failed with 1603")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "1 error" })); + expect(screen.getByText(/L42/)).toBeInTheDocument(); + expect(screen.getByText("CustomAction failed")).toBeInTheDocument(); + + expect(screen.getByText("Succeeded / Deferred")).toBeInTheDocument(); + expect(screen.getByText("Good MSI")).toBeInTheDocument(); + expect(screen.getByText("Deferred Burn")).toBeInTheDocument(); + expect(screen.getByText("Other / Unclassified (1)")).toBeInTheDocument(); + expect(screen.getAllByText("Application").length).toBeGreaterThan(0); + }); + it("DEP-003 opens the failing file at its first error line", async () => { + seedReady(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open in Log Viewer" })); + + expect(useLogStore.getState().pendingScrollTarget).toEqual({ + filePath: "C:\\Windows\\Logs\\Software\\fail.log", + lineNumber: 42, + }); + expect(useUiStore.getState().activeView).toBe("log"); + await waitFor(() => + expect(loadPathAsLogSource).toHaveBeenCalledWith( + "C:\\Windows\\Logs\\Software\\fail.log", + ), + ); + }); +}); diff --git a/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx new file mode 100644 index 000000000..4df3b76fe --- /dev/null +++ b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx @@ -0,0 +1,419 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DsregcmdSidebar } from "./DsregcmdSidebar"; +import { DsregcmdWorkspace } from "./DsregcmdWorkspace"; +import { useDsregcmdStore } from "./dsregcmd-store"; +import type { + DsregcmdAnalysisResult, + DsregcmdFacts, + DsregcmdPolicyEvidenceValue, + DsregcmdSourceContext, + DsregcmdWhfbPolicyEvidence, +} from "./types"; +import type { EventLogAnalysis, EventLogEntry } from "../../types/event-log"; +import { useUiStore } from "../../stores/ui-store"; + +vi.mock("../../hooks/use-app-actions", () => ({ + useAppActions: () => ({ + openSourceFileDialog: vi.fn(), + openSourceFolderDialog: vi.fn(), + pasteDsregcmdSource: vi.fn(), + captureDsregcmdSource: vi.fn(), + commandState: { canRefresh: false }, + refreshActiveSource: vi.fn(), + }), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 28, + size: 28, + })), + getTotalSize: () => count * 28, + measureElement: vi.fn(), + scrollToIndex: vi.fn(), + }), +})); + +function policyValue( + overrides: Partial = {}, +): DsregcmdPolicyEvidenceValue { + return { + displayValue: true, + currentValue: true, + providerValue: true, + source: "windows_policy_machine", + note: null, + ...overrides, + }; +} + +function fixtureFacts(): DsregcmdFacts { + return { + joinState: { + azureAdJoined: true, + domainJoined: true, + workplaceJoined: null, + enterpriseJoined: null, + }, + deviceDetails: { + deviceId: null, + thumbprint: null, + deviceCertificateValidity: null, + keyContainerId: null, + keyProvider: null, + tpmProtected: null, + deviceAuthStatus: "SUCCESS", + }, + tenantDetails: { + tenantId: null, + tenantName: null, + domainName: null, + idp: null, + }, + managementDetails: { + mdmUrl: null, + mdmComplianceUrl: null, + mdmTouUrl: null, + settingsUrl: null, + deviceManagementSrvVer: null, + deviceManagementSrvUrl: null, + deviceManagementSrvId: null, + }, + serviceEndpoints: { + authCodeUrl: null, + accessTokenUrl: null, + joinSrvVersion: null, + joinSrvUrl: null, + joinSrvId: null, + keySrvVersion: null, + keySrvUrl: null, + keySrvId: null, + webAuthnSrvVersion: null, + webAuthnSrvUrl: null, + webAuthnSrvId: null, + }, + userState: { + ngcSet: true, + ngcKeyId: null, + canReset: null, + wamDefaultSet: null, + wamDefaultAuthority: null, + wamDefaultId: null, + wamDefaultGuid: null, + isDeviceJoined: null, + isUserAzureAd: null, + policyEnabled: null, + postLogonEnabled: null, + deviceEligible: null, + sessionIsNotRemote: null, + }, + ssoState: { + azureAdPrt: true, + azureAdPrtAuthority: null, + azureAdPrtUpdateTime: null, + acquirePrtDiagnostics: null, + enterprisePrt: null, + enterprisePrtUpdateTime: null, + enterprisePrtExpiryTime: null, + enterprisePrtAuthority: null, + onPremTgt: null, + cloudTgt: null, + adfsRefreshToken: null, + adfsRaIsReady: null, + kerbTopLevelNames: null, + }, + diagnostics: { + previousPrtAttempt: null, + attemptStatus: null, + userIdentity: null, + credentialType: null, + correlationId: null, + endpointUri: null, + httpMethod: null, + httpError: null, + httpStatus: null, + requestId: null, + diagnosticsReference: null, + userContext: null, + clientTime: null, + }, + preJoinTests: { + adConnectivityTest: null, + adConfigurationTest: null, + drsDiscoveryTest: null, + drsConnectivityTest: null, + tokenAcquisitionTest: null, + fallbackToSyncJoin: null, + }, + registration: { + previousRegistration: null, + errorPhase: null, + certEnrollment: null, + logonCertTemplateReady: null, + preReqResult: null, + clientErrorCode: null, + serverErrorCode: null, + serverMessage: null, + serverErrorDescription: null, + }, + postJoinDiagnostics: { + aadRecoveryEnabled: null, + keySignTest: null, + }, + }; +} + +function policyEvidence(): DsregcmdWhfbPolicyEvidence { + return { + policyEnabled: policyValue(), + postLogonEnabled: policyValue(), + pinRecoveryEnabled: policyValue({ displayValue: false }), + requireSecurityDevice: policyValue(), + useCertificateForOnPremAuth: policyValue({ displayValue: false }), + useCloudTrustForOnPremAuth: policyValue(), + artifactPaths: ["HKLM\\SOFTWARE\\Policies\\Microsoft\\PassportForWork"], + }; +} + +function eventLogAnalysis(): EventLogAnalysis { + const entry: EventLogEntry = { + id: 1, + channel: "AadOperational", + channelDisplay: "AAD Operational", + provider: "Microsoft-Windows-AAD", + eventId: 1098, + severity: "Error", + timestamp: "2026-01-15T12:00:00.000Z", + computer: "PC01", + message: "PRT refresh failed", + correlationActivityId: null, + sourceFile: "AAD.evtx", + }; + return { + sourceKind: "Bundle", + entries: [entry], + channelSummaries: [ + { + channel: "AadOperational", + channelDisplay: "AAD Operational", + entryCount: 1, + errorCount: 1, + warningCount: 0, + timestampBounds: null, + sourceFile: "AAD.evtx", + }, + ], + correlationLinks: [], + parsedFileCount: 1, + totalEntryCount: 1, + errorEntryCount: 1, + warningEntryCount: 0, + timestampBounds: null, + liveQuery: { + attemptedChannelCount: 2, + successfulChannelCount: 1, + channelsWithResultsCount: 1, + failedChannelCount: 1, + perChannelEntryLimit: 500, + channels: [], + }, + }; +} + +function analysisResult(): DsregcmdAnalysisResult { + return { + facts: fixtureFacts(), + derived: { + joinType: "HybridEntraIdJoined", + joinTypeLabel: "Hybrid Entra ID joined", + dominantPhase: "auth", + phaseSummary: "Authentication is the current problem phase.", + captureConfidence: "high", + captureConfidenceReason: "Live capture includes dsregcmd and registry evidence.", + mdmEnrolled: true, + missingMdm: false, + complianceUrlPresent: true, + missingComplianceUrl: false, + azureAdPrtPresent: true, + stalePrt: false, + prtLastUpdate: null, + prtReferenceTime: null, + prtAgeHours: 1, + tpmProtected: null, + certificateValidFrom: null, + certificateValidTo: null, + certificateExpiringSoon: false, + certificateDaysRemaining: 90, + networkErrorCode: null, + hasNetworkError: false, + remoteSessionSystem: false, + }, + diagnostics: [ + { + id: "prt-stale", + severity: "Warning", + category: "SSO", + title: "PRT may need a refresh", + summary: "Primary Refresh Token age is approaching the stale threshold.", + evidence: ["azureAdPrt=YES"], + nextChecks: ["dsregcmd /status"], + suggestedFixes: ["Sign out and sign in again"], + }, + ], + policyEvidence: policyEvidence(), + osVersion: { + currentBuild: "26100", + displayVersion: "24H2", + productName: "Windows 11", + ubr: 1, + editionId: "Enterprise", + }, + proxyEvidence: { + proxyEnabled: false, + proxyServer: null, + proxyOverride: null, + autoConfigUrl: null, + wpadDetected: false, + winhttpProxy: null, + }, + enrollmentEvidence: { + enrollmentCount: 1, + enrollments: [ + { + guid: "11111111-1111-1111-1111-111111111111", + upn: "user@contoso.com", + providerId: "MS DM Server", + enrollmentState: 1, + }, + ], + }, + activeEvidence: { + connectivityTests: [ + { + endpoint: "https://login.microsoftonline.com", + reachable: true, + statusCode: 200, + latencyMs: 40, + errorMessage: null, + timestamp: "2026-01-15T12:00:00.000Z", + }, + ], + scpQuery: { + scpFound: true, + tenantDomain: "contoso.com", + azureadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + keywords: ["aADDomainName"], + domainController: "dc01.contoso.com", + error: null, + }, + }, + scheduledTaskEvidence: { enterpriseMgmtGuids: [] }, + eventLogAnalysis: eventLogAnalysis(), + }; +} + +function sourceContext(): DsregcmdSourceContext { + return { + source: { kind: "file", path: "C:\\temp\\dsregcmd.txt" }, + requestedPath: "C:\\temp\\dsregcmd.txt", + resolvedPath: "C:\\temp\\dsregcmd.txt", + bundlePath: null, + displayLabel: "dsregcmd.txt", + evidenceFilePath: "C:\\temp\\dsregcmd.txt", + rawLineCount: 40, + rawCharCount: 800, + }; +} + +function seedReady() { + useDsregcmdStore + .getState() + .setResults("AzureAdJoined : YES", analysisResult(), sourceContext()); +} + +afterEach(() => { + cleanup(); + useDsregcmdStore.getState().clear(); +}); + +beforeEach(() => { + useUiStore.setState({ currentPlatform: "windows" }); + useDsregcmdStore.getState().clear(); +}); + +describe("DsregcmdWorkspace fixtures", () => { + it("DSREG-003 shows health cards, issues overview, and sidebar findings", () => { + seedReady(); + render( + <> + + + , + ); + + expect(screen.getAllByText("Join Type").length).toBeGreaterThan(0); + expect(screen.getAllByText("Current Stage").length).toBeGreaterThan(0); + expect(screen.getAllByText("Capture Confidence").length).toBeGreaterThan(0); + expect(screen.getByText("PRT State")).toBeInTheDocument(); + expect(screen.getByText("MDM Signals")).toBeInTheDocument(); + expect(screen.getByText("NGC")).toBeInTheDocument(); + expect(screen.getAllByText("Certificate").length).toBeGreaterThan(0); + expect(screen.getByText("90 days")).toBeInTheDocument(); + expect(screen.getByText("Issues Overview")).toBeInTheDocument(); + expect(screen.getByText("Evidence")).toBeInTheDocument(); + expect(screen.getByText("Next checks")).toBeInTheDocument(); + expect(screen.getByText("Suggested fixes")).toBeInTheDocument(); + expect(screen.getByText("Top Findings")).toBeInTheDocument(); + expect(screen.getAllByText("PRT may need a refresh").length).toBeGreaterThan(0); + }); + + it("DSREG-004 shows fact groups including Policy Evidence, timeline, and flows", () => { + seedReady(); + render(); + + expect(screen.getByText("Facts by Group")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Show not reported fields" }), + ).toBeInTheDocument(); + expect(screen.getByText("Policy Evidence")).toBeInTheDocument(); + expect(screen.getByText("Join State")).toBeInTheDocument(); + expect(screen.getByText("Operating System")).toBeInTheDocument(); + expect(screen.getByText("Proxy Configuration")).toBeInTheDocument(); + expect(screen.getByText("Enrollment Status")).toBeInTheDocument(); + expect(screen.getByText("SCP Configuration")).toBeInTheDocument(); + expect(screen.getByText("Endpoint Connectivity")).toBeInTheDocument(); + expect(screen.getByText("Timeline")).toBeInTheDocument(); + expect(screen.getByText("Flows")).toBeInTheDocument(); + }); + + it("DSREG-005 shows the Event Logs surface with channel and severity filters", () => { + seedReady(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Event Logs/ })); + + expect(screen.getByText("Channel:")).toBeInTheDocument(); + expect(screen.getByText("Severity:")).toBeInTheDocument(); + expect(screen.getByText("1 of 1 entries")).toBeInTheDocument(); + expect(screen.getAllByText("AAD Operational").length).toBeGreaterThan(0); + expect(screen.getByText("PRT refresh failed")).toBeInTheDocument(); + }); + + it("DSREG-006 shows export controls for JSON, status, summary, and raw input", () => { + seedReady(); + render(); + + expect(screen.getByText("Export")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy JSON" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy status text" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy summary" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save JSON..." })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save summary..." })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show raw input" })).toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/event-log/EventLogWorkspace.test.tsx b/src/workspaces/event-log/EventLogWorkspace.test.tsx new file mode 100644 index 000000000..face3fce3 --- /dev/null +++ b/src/workspaces/event-log/EventLogWorkspace.test.tsx @@ -0,0 +1,142 @@ +/** + * Event Log workspace fixtures. Mock Tauri before importing evtx-store: + * the store registers event listeners at module scope. + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EvtxRecord } from "./types"; + +const invoke = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => undefined), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 28, + size: 28, + })), + getTotalSize: () => count * 28, + measureElement: vi.fn(), + scrollToIndex: vi.fn(), + }), +})); + +const { EventLogWorkspace } = await import("./EventLogWorkspace"); +const { useEvtxStore } = await import("./evtx-store"); +const { defaultColumnConfig } = await import("./evtx-columns"); + +function record(): EvtxRecord { + return { + id: 0, + eventRecordId: 42, + timestamp: "2026-01-15T12:00:00.000Z", + timestampEpoch: Date.parse("2026-01-15T12:00:00.000Z"), + provider: "Application Error", + channel: "Application", + eventId: 1000, + level: "Error", + computer: "PC01", + message: "Faulting application name: setup.exe", + eventData: [{ name: "AppName", value: "setup.exe" }], + rawXml: "1000", + sourceLabel: "Application.evtx", + }; +} + +function seedEvents() { + useEvtxStore.setState({ + records: [record()], + channels: [ + { name: "Application", eventCount: 1, sourceType: "live" }, + { name: "System", eventCount: 0, sourceType: "live" }, + { + name: "Microsoft-Windows-AAD/Operational", + eventCount: 0, + sourceType: "live", + }, + ], + sourceMode: "files", + isLoading: false, + loadError: null, + coverageGaps: [], + selectedChannels: new Set(["Application", "System", "Microsoft-Windows-AAD/Operational"]), + loadedChannels: new Set(["Application"]), + filterLevels: new Set(["Critical", "Error", "Warning", "Information", "Verbose"]), + filterEventIds: "", + filterSearch: "", + timeWindow: "24h", + timeZoneMode: "local", + columnConfig: defaultColumnConfig(), + groupBy: [], + collapsedGroups: new Set(), + sortField: "time", + sortDirection: "asc", + selectedRecordId: null, + }); +} + +afterEach(() => { + cleanup(); + useEvtxStore.getState().reset(); +}); + +beforeEach(() => { + invoke.mockReset(); + useEvtxStore.getState().reset(); +}); + +describe("EventLogWorkspace fixtures", () => { + it("EVTX-003 shows the Windows Logs / Applications tree with select controls", () => { + seedEvents(); + render(); + + expect(screen.getByText("Windows Logs")).toBeInTheDocument(); + expect(screen.getByText("Applications and Services Logs")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Filter channels...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select all" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Deselect all" })).toBeInTheDocument(); + expect(screen.getAllByText("Application").length).toBeGreaterThan(0); + }); + + it("EVTX-006 offers CSV, TSV, JSON, and Event XML export of visible events", () => { + seedEvents(); + render(); + + fireEvent.click( + screen.getByTitle( + "Export the events currently shown, using the same filters as the list", + ), + ); + + expect(screen.getByText("CSV")).toBeInTheDocument(); + expect(screen.getByText("TSV")).toBeInTheDocument(); + expect(screen.getByText("JSON")).toBeInTheDocument(); + expect(screen.getByText("Event XML")).toBeInTheDocument(); + }); + + it("EVTX-007 shows event detail, Event Data, and Show/Hide Raw XML", () => { + seedEvents(); + render(); + + fireEvent.click(screen.getByRole("option")); + + expect(screen.getByText("Event 1000")).toBeInTheDocument(); + expect( + screen.getAllByText("Faulting application name: setup.exe").length, + ).toBeGreaterThan(0); + expect(screen.getByText("Event Data")).toBeInTheDocument(); + expect(screen.getByText("AppName")).toBeInTheDocument(); + expect(screen.getAllByText("Application Error").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: "Show Raw XML" })); + expect(screen.getByRole("button", { name: "Hide Raw XML" })).toBeInTheDocument(); + expect(screen.getByText(/1000<\/EventID>/)).toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/event-log/evtx-store-coverage.test.ts b/src/workspaces/event-log/evtx-store-coverage.test.ts index 79ee065ad..b85970f9d 100644 --- a/src/workspaces/event-log/evtx-store-coverage.test.ts +++ b/src/workspaces/event-log/evtx-store-coverage.test.ts @@ -123,6 +123,16 @@ describe("coverage gaps through the store", () => { expect(state.loadError).toContain("access denied"); expect(state.isLoading).toBe(false); }); + it("clears a load error without ending an active load", () => { + useEvtxStore.setState({ isLoading: true, loadError: "stale error" }); + + useEvtxStore.getState().setLoadError(null); + + expect(useEvtxStore.getState()).toMatchObject({ + isLoading: true, + loadError: null, + }); + }); }); describe("a multi-channel query is delivered one channel at a time", () => { diff --git a/src/workspaces/event-log/evtx-store.ts b/src/workspaces/event-log/evtx-store.ts index 304a1e13e..19f202fc7 100644 --- a/src/workspaces/event-log/evtx-store.ts +++ b/src/workspaces/event-log/evtx-store.ts @@ -86,6 +86,7 @@ interface EvtxState { queryChannels: (channels: string[], maxEvents?: number) => Promise; loadSelectedChannels: () => Promise; refreshLoadedChannels: () => Promise; + setLoadError: (error: string | null) => void; setTimeZoneMode: (mode: EvtxTimeZoneMode) => void; setSelectedChannels: (channels: Set) => void; toggleChannel: (channel: string) => void; @@ -158,6 +159,7 @@ export const useEvtxStore = create()((set, get) => ({ } catch (error) { const message = error instanceof Error ? error.message : String(error); set({ isLoading: false, loadError: message }); + throw error; } }, @@ -448,6 +450,12 @@ export const useEvtxStore = create()((set, get) => ({ loadElapsedMs: performance.now() - startTime, }); }, + setLoadError: (error) => + set( + error === null + ? { loadError: null } + : { isLoading: false, loadError: error }, + ), setTimeZoneMode: (mode) => set({ timeZoneMode: mode }), diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts index 7c32ffc8b..530581c77 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -1,5 +1,6 @@ // src/workspaces/event-log/index.ts import { lazy } from "react"; +import { useUiStore } from "../../stores/ui-store"; import type { WorkspaceDefinition } from "../types"; export const eventLogWorkspace: WorkspaceDefinition = { @@ -23,4 +24,29 @@ export const eventLogWorkspace: WorkspaceDefinition = { folder: "Open EVTX folder...", placeholder: "Open event log source...", }, + onOpenSource: async (source, trigger) => { + useUiStore.getState().ensureWorkspaceVisible("event-log", trigger); + // Lazy: evtx-store registers Tauri event listeners at module load. + try { + const { openEventLogSource } = await import("./open-event-log-source"); + await openEventLogSource(source); + } catch (error) { + console.error("[event-log] failed to open source", { + source, + trigger, + error, + }); + try { + const { useEvtxStore } = await import("./evtx-store"); + useEvtxStore.getState().setLoadError( + error instanceof Error ? error.message : String(error), + ); + } catch (storeError) { + console.error("[event-log] failed to record load error", storeError); + } + if (trigger === "drag-drop.path-open") { + throw error; + } + } + }, }; diff --git a/src/workspaces/event-log/open-event-log-source.test.ts b/src/workspaces/event-log/open-event-log-source.test.ts new file mode 100644 index 000000000..9e973b91e --- /dev/null +++ b/src/workspaces/event-log/open-event-log-source.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => undefined), +})); + +vi.mock("../../lib/commands", () => ({ + listLogFolder: vi.fn(), +})); + +const { listLogFolder } = await import("../../lib/commands"); +const { openEventLogSource } = await import("./open-event-log-source"); +const { useEvtxStore } = await import("./evtx-store"); +const actualParseFiles = useEvtxStore.getState().parseFiles; + +describe("openEventLogSource", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEvtxStore.setState({ + parseFiles: vi.fn(async () => undefined), + setLoadError: vi.fn(), + }); + }); + + it("parses a single evtx file", async () => { + await openEventLogSource({ kind: "file", path: "/tmp/Application.evtx" }); + expect(useEvtxStore.getState().parseFiles).toHaveBeenCalledWith([ + "/tmp/Application.evtx", + ]); + }); + it("propagates file parse failures to the caller", async () => { + const parseFiles = actualParseFiles; + useEvtxStore.setState({ + parseFiles, + setLoadError: vi.fn(), + }); + invoke.mockRejectedValueOnce(new Error("not a file")); + + await expect( + openEventLogSource({ kind: "file", path: "/tmp/not-a-file" }), + ).rejects.toThrow("not a file"); + expect(useEvtxStore.getState().loadError).toBe("not a file"); + }); + it("parses a known file source using its default path", async () => { + const defaultPath = "/tmp/Application.evtx"; + + await openEventLogSource({ + kind: "known", + sourceId: "known-application", + defaultPath, + pathKind: "file", + }); + + expect(useEvtxStore.getState().parseFiles).toHaveBeenCalledWith([defaultPath]); + expect(listLogFolder).not.toHaveBeenCalled(); + }); + + it("parses evtx files from a known folder source", async () => { + const defaultPath = "/tmp/logs"; + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { + kind: "known", + sourceId: "known-logs", + defaultPath, + pathKind: "folder", + }, + entries: [ + { + name: "SYSTEM.EVTX", + path: `${defaultPath}/SYSTEM.EVTX`, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + { + name: "notes.txt", + path: `${defaultPath}/notes.txt`, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + { + name: "nested.evtx", + path: `${defaultPath}/nested.evtx`, + isDir: true, + sizeBytes: null, + modifiedUnixMs: null, + }, + ], + }); + + await openEventLogSource({ + kind: "known", + sourceId: "known-logs", + defaultPath, + pathKind: "folder", + }); + + expect(listLogFolder).toHaveBeenCalledWith(defaultPath); + expect(useEvtxStore.getState().parseFiles).toHaveBeenCalledWith([ + `${defaultPath}/SYSTEM.EVTX`, + ]); + }); + + it("rejects a known folder with no evtx files", async () => { + const defaultPath = "/tmp/empty"; + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { + kind: "known", + sourceId: "known-empty", + defaultPath, + pathKind: "folder", + }, + entries: [], + }); + + await expect( + openEventLogSource({ + kind: "known", + sourceId: "known-empty", + defaultPath, + pathKind: "folder", + }), + ).rejects.toThrow("No .evtx files were found for that known source."); + expect(useEvtxStore.getState().setLoadError).toHaveBeenCalledWith( + "No .evtx files were found for that known source.", + ); + expect(useEvtxStore.getState().parseFiles).not.toHaveBeenCalled(); + }); + + it("parses evtx files from a folder and ignores other names", async () => { + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/logs" }, + entries: [ + { + name: "Application.evtx", + path: "/tmp/logs/Application.evtx", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + { + name: "notes.txt", + path: "/tmp/logs/notes.txt", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + { + name: "nested", + path: "/tmp/logs/nested", + isDir: true, + sizeBytes: null, + modifiedUnixMs: null, + }, + ], + }); + + await openEventLogSource({ kind: "folder", path: "/tmp/logs" }); + expect(useEvtxStore.getState().parseFiles).toHaveBeenCalledWith([ + "/tmp/logs/Application.evtx", + ]); + }); + + it("rejects a folder with no evtx files", async () => { + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/empty" }, + entries: [], + }); + + await expect( + openEventLogSource({ kind: "folder", path: "/tmp/empty" }), + ).rejects.toThrow(/No \.evtx files/); + expect(useEvtxStore.getState().setLoadError).toHaveBeenCalledWith( + "No .evtx files were found in that folder. Choose a folder that contains Windows Event Log files.", + ); + }); +}); diff --git a/src/workspaces/event-log/open-event-log-source.ts b/src/workspaces/event-log/open-event-log-source.ts new file mode 100644 index 000000000..adac2f43e --- /dev/null +++ b/src/workspaces/event-log/open-event-log-source.ts @@ -0,0 +1,49 @@ +import { listLogFolder } from "../../lib/commands"; +import type { FolderEntry, LogSource } from "../../types/log"; +import { useEvtxStore } from "./evtx-store"; + +function evtxPathsFromFolderEntries(entries: FolderEntry[]): string[] { + return entries + .filter((entry) => !entry.isDir && entry.name.toLowerCase().endsWith(".evtx")) + .map((entry) => entry.path); +} + +export async function openEventLogSource(source: LogSource): Promise { + const parseFiles = useEvtxStore.getState().parseFiles; + + try { + if (source.kind === "file") { + await parseFiles([source.path]); + return; + } + + if (source.kind === "folder") { + const listing = await listLogFolder(source.path); + const evtxPaths = evtxPathsFromFolderEntries(listing.entries); + if (evtxPaths.length === 0) { + throw new Error( + "No .evtx files were found in that folder. Choose a folder that contains Windows Event Log files.", + ); + } + await parseFiles(evtxPaths); + return; + } + + if (source.pathKind === "file") { + await parseFiles([source.defaultPath]); + return; + } + + const listing = await listLogFolder(source.defaultPath); + const evtxPaths = evtxPathsFromFolderEntries(listing.entries); + if (evtxPaths.length === 0) { + throw new Error("No .evtx files were found for that known source."); + } + await parseFiles(evtxPaths); + } catch (error) { + useEvtxStore.getState().setLoadError( + error instanceof Error ? error.message : String(error), + ); + throw error; + } +} diff --git a/src/workspaces/intune/IntuneDashboard.stories.test.tsx b/src/workspaces/intune/IntuneDashboard.stories.test.tsx new file mode 100644 index 000000000..846afa69d --- /dev/null +++ b/src/workspaces/intune/IntuneDashboard.stories.test.tsx @@ -0,0 +1,384 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DownloadStats } from "./DownloadStats"; +import { IntuneDashboard } from "./IntuneDashboard"; +import { IntuneSidebar } from "./IntuneSidebar"; +import { useIntuneStore } from "./intune-store"; +import type { IntuneResultMetadata } from "./types"; +import { + ANALYZED_PATH, + APP_GUID, + APPWORKLOAD_PATH, + DOWNLOAD_NAME, + FAILED_EVENT_NAME, + GRAPH_APP_NAME, + GRAPH_GUID_REGISTRY, + GUID_ONLY_EVENT, + SCRIPT_BODY, + SCRIPT_EVENT_NAME, + STORY_DOWNLOADS, + STORY_EVENTS, + STORY_SOURCE_FILES, + SUMMARY, + DIAGNOSTIC, +} from "./intune-story-fixtures"; + +vi.mock("../../hooks/use-app-actions", () => ({ + useAppActions: () => ({ + commandState: { + canOpenSources: true, + canOpenKnownSources: true, + canRefresh: true, + }, + openSourceFileDialog: vi.fn(), + openSourceFolderDialog: vi.fn(), + }), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ + count, + estimateSize, + getItemKey, + }: { + count: number; + estimateSize: (index: number) => number; + getItemKey?: (index: number) => string | number; + }) => ({ + getTotalSize: () => { + let total = 0; + for (let index = 0; index < count; index += 1) { + total += estimateSize(index); + } + return total; + }, + getVirtualItems: () => { + let start = 0; + return Array.from({ length: count }, (_, index) => { + const size = estimateSize(index); + const item = { + index, + key: getItemKey?.(index) ?? index, + size, + start, + }; + start += size; + return item; + }); + }, + scrollToIndex: vi.fn(), + measureElement: vi.fn(), + }), +})); + +function seedReadyResults(metadata?: Partial) { + act(() => { + useIntuneStore.getState().beginAnalysis(ANALYZED_PATH, "folder"); + useIntuneStore.getState().setResults( + STORY_EVENTS, + STORY_DOWNLOADS, + SUMMARY, + [DIAGNOSTIC], + ANALYZED_PATH, + STORY_SOURCE_FILES, + metadata, + ); + }); +} + +function tabButton(label: string) { + return screen.getByRole("button", { name: new RegExp(`^${label}\\d*$`) }); +} + +function expectVisibleName(name: string) { + expect(screen.getAllByText(name).length).toBeGreaterThan(0); +} + +afterEach(() => { + cleanup(); + useIntuneStore.getState().clear(); +}); + +beforeEach(() => { + useIntuneStore.getState().clear(); + vi.mocked(writeText).mockReset(); +}); + +describe("INTUNE-002 classic Timeline / Downloads / Summary tabs", () => { + it("renders Timeline, Downloads, and Summary and switches among them", () => { + seedReadyResults(); + render(); + + expect(tabButton("Timeline")).not.toBeDisabled(); + expect(tabButton("Downloads")).not.toBeDisabled(); + expect(tabButton("Summary")).not.toBeDisabled(); + expectVisibleName(FAILED_EVENT_NAME); + + fireEvent.click(tabButton("Downloads")); + expect(screen.getByText(DOWNLOAD_NAME)).toBeInTheDocument(); + expect(screen.queryByText(FAILED_EVENT_NAME)).not.toBeInTheDocument(); + + fireEvent.click(tabButton("Summary")); + expect(screen.getByText("Intune Diagnostics Summary")).toBeInTheDocument(); + expect(screen.queryByText(DOWNLOAD_NAME)).not.toBeInTheDocument(); + }); + + it("disables empty surfaces and falls back when the active tab has no data", () => { + act(() => { + useIntuneStore.getState().beginAnalysis(ANALYZED_PATH, "folder"); + useIntuneStore.getState().setResults( + STORY_EVENTS, + [], + { ...SUMMARY, totalDownloads: 0, successfulDownloads: 0 }, + [DIAGNOSTIC], + ANALYZED_PATH, + STORY_SOURCE_FILES, + ); + useIntuneStore.getState().setActiveTab("downloads"); + }); + + render(); + + expect(tabButton("Downloads")).toBeDisabled(); + expectVisibleName(FAILED_EVENT_NAME); + expect(useIntuneStore.getState().activeTab).toBe("timeline"); + }); + + it("disables tabs while analyzing", () => { + seedReadyResults(); + act(() => { + useIntuneStore.setState({ isAnalyzing: true }); + }); + render(); + + expect(tabButton("Timeline")).toBeDisabled(); + expect(tabButton("Downloads")).toBeDisabled(); + expect(tabButton("Summary")).toBeDisabled(); + }); +}); + +describe("INTUNE-003 time window filter", () => { + it("offers All Activity / Last Hour / Last 6 Hours / Last Day / Last 7 Days", () => { + seedReadyResults(); + render(); + + const windowSelect = screen.getByDisplayValue("All Activity"); + expect(within(windowSelect).getByRole("option", { name: "All Activity" })).toBeInTheDocument(); + expect(within(windowSelect).getByRole("option", { name: "Last Hour" })).toBeInTheDocument(); + expect(within(windowSelect).getByRole("option", { name: "Last 6 Hours" })).toBeInTheDocument(); + expect(within(windowSelect).getByRole("option", { name: "Last Day" })).toBeInTheDocument(); + expect(within(windowSelect).getByRole("option", { name: "Last 7 Days" })).toBeInTheDocument(); + }); + + it("anchors the window to the latest event and leaves summary diagnostics unwindowed", () => { + seedReadyResults(); + render(); + + expect(screen.getByText(SCRIPT_EVENT_NAME)).toBeInTheDocument(); + fireEvent.change(screen.getByDisplayValue("All Activity"), { + target: { value: "last-day" }, + }); + + expect(screen.getByDisplayValue("Last Day")).toBeInTheDocument(); + expectVisibleName(FAILED_EVENT_NAME); + expect(screen.queryByText(SCRIPT_EVENT_NAME)).not.toBeInTheDocument(); + + fireEvent.click(tabButton("Summary")); + expect( + screen.getByText( + /Diagnostics guidance, confidence, and repeated-failure analysis still reflect the full analyzed source set/, + ), + ).toBeInTheDocument(); + expect(screen.getAllByText("Win32 content download failed").length).toBeGreaterThan(0); + }); +}); + +describe("INTUNE-004 timeline type/status/sort/activity", () => { + it("filters by type and status, resets, sorts, and switches list vs activity", () => { + seedReadyResults(); + render(); + + const typeSelect = screen.getByDisplayValue("All Types"); + expect(within(typeSelect).getByRole("option", { name: "Win32" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "WinGet" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "Script" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "Remediation" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "ESP" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "Sync" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "Policy" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "Download" })).toBeInTheDocument(); + expect(within(typeSelect).getByRole("option", { name: "Other" })).toBeInTheDocument(); + + const statusSelect = screen.getByDisplayValue("All Statuses"); + expect(within(statusSelect).getByRole("option", { name: "Success" })).toBeInTheDocument(); + expect(within(statusSelect).getByRole("option", { name: "Failed" })).toBeInTheDocument(); + expect(within(statusSelect).getByRole("option", { name: "In Progress" })).toBeInTheDocument(); + expect(within(statusSelect).getByRole("option", { name: "Pending" })).toBeInTheDocument(); + expect(within(statusSelect).getByRole("option", { name: "Timeout" })).toBeInTheDocument(); + expect(within(statusSelect).getByRole("option", { name: "Unknown" })).toBeInTheDocument(); + + fireEvent.change(typeSelect, { target: { value: "PowerShellScript" } }); + expect(screen.getByText(SCRIPT_EVENT_NAME)).toBeInTheDocument(); + expect(screen.queryByText(FAILED_EVENT_NAME)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + expectVisibleName(FAILED_EVENT_NAME); + + fireEvent.change(screen.getByDisplayValue("All Statuses"), { + target: { value: "Failed" }, + }); + expect(screen.getAllByText(FAILED_EVENT_NAME).length).toBeGreaterThan(0); + expect(screen.queryByText(SCRIPT_EVENT_NAME)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + const sortSelect = screen.getByDisplayValue("Time"); + expect(within(sortSelect).getByRole("option", { name: "Name" })).toBeInTheDocument(); + expect(within(sortSelect).getByRole("option", { name: "Type" })).toBeInTheDocument(); + expect(within(sortSelect).getByRole("option", { name: "Status" })).toBeInTheDocument(); + expect(within(sortSelect).getByRole("option", { name: "Duration" })).toBeInTheDocument(); + fireEvent.change(sortSelect, { target: { value: "name" } }); + expect(useIntuneStore.getState().sortField).toBe("name"); + + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + expect(screen.getByRole("tree", { name: /Activity groups/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "List" })); + expect(screen.getByRole("listbox", { name: /Intune event timeline/ })).toBeInTheDocument(); + }); +}); + +describe("INTUNE-005 scope timeline to one included file", () => { + it("scopes from the sidebar and clears from the nav chip", () => { + seedReadyResults(); + render( + <> + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /AppWorkload\.log/ })); + expect(screen.getByText("Scoped")).toBeInTheDocument(); + expect(screen.getByText(/Timeline scoped to AppWorkload\.log/)).toBeInTheDocument(); + expectVisibleName(FAILED_EVENT_NAME); + expect(screen.queryByText(SCRIPT_EVENT_NAME)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Clear Scope" })); + expect(screen.queryByText("Scoped")).not.toBeInTheDocument(); + expect(screen.getByText(SCRIPT_EVENT_NAME)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /AppWorkload\.log/ })); + fireEvent.click(screen.getByRole("button", { name: /AppWorkload\.log/ })); + expect(useIntuneStore.getState().timelineScope.filePath).toBeNull(); + }); +}); + +describe("INTUNE-006 inspect and copy an IME event", () => { + it("expands a failed event and copies error context plus script body", async () => { + seedReadyResults(); + render(); + + fireEvent.click(screen.getAllByRole("option", { name: /Win32 App Install Failed/ })[0]); + expect(screen.getByText("Failure context")).toBeInTheDocument(); + expect(screen.getByText(/AppWorkload context:/)).toBeInTheDocument(); + expect(screen.getByText(`${APPWORKLOAD_PATH.split("/").pop()}:12`)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Copy error + context" })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("Error: 0x87D30067")); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.change(screen.getByDisplayValue("All Types"), { + target: { value: "PowerShellScript" }, + }); + fireEvent.click(screen.getByRole("option", { name: /Inventory Collection/ })); + expect(screen.getByText(/Collect inventory/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + expect(writeText).toHaveBeenCalledWith(SCRIPT_BODY); + }); +}); + +describe("INTUNE-007 download statistics table", () => { + it("shows sortable headers, aggregates, and the download row", () => { + seedReadyResults(); + render(); + fireEvent.click(tabButton("Downloads")); + + expect(screen.getByText("1 files")).toBeInTheDocument(); + expect(screen.getByText(/Success:/)).toBeInTheDocument(); + expect(screen.getByText(/Failure:/)).toBeInTheDocument(); + expect(screen.getByText(/Transferred:/)).toBeInTheDocument(); + expect(screen.getAllByText("1.0 MB").length).toBeGreaterThan(0); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Content/ })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Size/ })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Speed/ })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /DO %/ })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Dur\./ })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Timestamp/ })).toBeInTheDocument(); + expect(screen.getByText(DOWNLOAD_NAME)).toBeInTheDocument(); + expect(screen.getByText("72.5%")).toBeInTheDocument(); + }); + + it("shows the empty download copy when no content events exist", () => { + render(); + expect( + screen.getByText("No content download events were found in this analysis."), + ).toBeInTheDocument(); + }); +}); + +describe("INTUNE-008 summary findings, coverage, confidence", () => { + it("renders conclusions, coverage, confidence, remediation, and activity metrics", () => { + seedReadyResults(); + render(); + fireEvent.click(tabButton("Summary")); + + expect(screen.getByText("Conclusions")).toBeInTheDocument(); + expect(screen.getByText("Diagnostics Coverage")).toBeInTheDocument(); + expect(screen.getAllByText("Confidence").length).toBeGreaterThan(0); + expect(screen.getByText("Repeated Failures")).toBeInTheDocument(); + expect(screen.getByText("Remediation Assistant")).toBeInTheDocument(); + expect(screen.getByText("Activity Metrics")).toBeInTheDocument(); + expect(screen.getByText("Files")).toBeInTheDocument(); + expect(screen.getByText("Families")).toBeInTheDocument(); + expect(screen.getByText("Rotated")).toBeInTheDocument(); + expect(screen.getByText("Dominant")).toBeInTheDocument(); + expect(screen.getByText(/Timestamp Bounds:/)).toBeInTheDocument(); + expect(screen.getByText("AppWorkload")).toBeInTheDocument(); + expect(screen.getByText("AgentExecutor")).toBeInTheDocument(); + expect(screen.getByText("Total Events")).toBeInTheDocument(); + expect(screen.getByText("Win32 Apps")).toBeInTheDocument(); + }); +}); + +describe("INTUNE-009 Graph GUID name enrichment", () => { + it("shows GraphApi names in activity view and has no Graph panel", () => { + act(() => { + useIntuneStore.getState().beginAnalysis(ANALYZED_PATH, "folder"); + useIntuneStore.getState().setResults( + [GUID_ONLY_EVENT], + STORY_DOWNLOADS, + { ...SUMMARY, totalEvents: 1, win32Apps: 0, scripts: 0, succeeded: 0, failed: 1 }, + [], + ANALYZED_PATH, + [APPWORKLOAD_PATH], + { guidRegistry: GRAPH_GUID_REGISTRY }, + ); + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + expect(screen.getByRole("tree", { name: /Activity groups/ })).toBeInTheDocument(); + expect(useIntuneStore.getState().guidRegistry[APP_GUID]?.source).toBe("GraphApi"); + expect(screen.getByTitle(GRAPH_APP_NAME)).toBeInTheDocument(); + expect(screen.queryByText(/Graph API/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/device picker/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx b/src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx new file mode 100644 index 000000000..348523c4f --- /dev/null +++ b/src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx @@ -0,0 +1,226 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NewIntuneWorkspace } from "./NewIntuneWorkspace"; +import { useIntuneStore } from "./intune-store"; +import type { IntuneResultMetadata } from "./types"; +import { + ANALYZED_PATH, + APPWORKLOAD_PATH, + DIAGNOSTIC, + DOWNLOAD_NAME, + EVENT_LOG_ANALYSIS, + FAILED_EVENT_NAME, + LIVE_EMPTY_EVENT_LOG_ANALYSIS, + STORY_DOWNLOADS, + STORY_EVENTS, + STORY_SOURCE_FILES, + SUMMARY, +} from "./intune-story-fixtures"; + +const openKnownSourceById = vi.fn(); +const openSourceFileDialog = vi.fn(); +const openSourceFolderDialog = vi.fn(); +const refreshActiveSource = vi.fn(); + +vi.mock("../../hooks/use-app-actions", () => ({ + useAppActions: () => ({ + commandState: { + canOpenSources: true, + canOpenKnownSources: true, + canRefresh: true, + }, + openKnownSourceById, + openSourceFileDialog, + openSourceFolderDialog, + refreshActiveSource, + }), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ + count, + estimateSize, + getItemKey, + }: { + count: number; + estimateSize: (index: number) => number; + getItemKey?: (index: number) => string | number; + }) => ({ + getTotalSize: () => { + let total = 0; + for (let index = 0; index < count; index += 1) { + total += estimateSize(index); + } + return total; + }, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: getItemKey?.(index) ?? index, + size: estimateSize(index), + start: index * estimateSize(index), + })), + scrollToIndex: vi.fn(), + measureElement: vi.fn(), + }), +})); + +function seedReadyResults(metadata?: Partial) { + act(() => { + useIntuneStore.getState().beginAnalysis(ANALYZED_PATH, "folder"); + useIntuneStore.getState().setResults( + STORY_EVENTS, + STORY_DOWNLOADS, + SUMMARY, + [DIAGNOSTIC], + ANALYZED_PATH, + STORY_SOURCE_FILES, + metadata, + ); + }); +} + +afterEach(() => { + cleanup(); + useIntuneStore.getState().clear(); +}); + +beforeEach(() => { + useIntuneStore.getState().clear(); + openKnownSourceById.mockReset(); + openSourceFileDialog.mockReset(); + openSourceFolderDialog.mockReset(); + refreshActiveSource.mockReset(); +}); + +describe("INTUNE-011 New Intune surfaces and reset", () => { + it("renders Overview, Event evidence, Download evidence, Event log evidence, Reset, and Refresh", () => { + seedReadyResults({ eventLogAnalysis: EVENT_LOG_ANALYSIS }); + render(); + + expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Event evidence" })).not.toBeDisabled(); + expect(screen.getByRole("tab", { name: "Download evidence" })).not.toBeDisabled(); + expect(screen.getByRole("tab", { name: /Event log evidence/ })).not.toBeDisabled(); + expect(screen.getByRole("tab", { name: /Event log evidence/ })).toHaveTextContent("1"); + expect(screen.getByRole("button", { name: "Reset investigation" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh analysis" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Event evidence" })); + expect(screen.getAllByText(FAILED_EVENT_NAME).length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("tab", { name: "Download evidence" })); + expect(screen.getByText(DOWNLOAD_NAME)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Refresh analysis" })); + expect(refreshActiveSource).toHaveBeenCalled(); + }); + + it("clears type, status, file scope, and selected event on Reset investigation", () => { + seedReadyResults(); + render(); + + act(() => { + useIntuneStore.getState().setFilterEventType("Win32App"); + useIntuneStore.getState().setFilterStatus("Failed"); + useIntuneStore.getState().setTimelineFileScope(APPWORKLOAD_PATH); + useIntuneStore.getState().selectEvent(1); + }); + + expect(screen.getByText("Type Win32 app")).toBeInTheDocument(); + expect(screen.getByText("Status Failed")).toBeInTheDocument(); + expect(screen.getByText("Scoped to AppWorkload.log")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reset investigation" })); + + expect(useIntuneStore.getState().filterEventType).toBe("All"); + expect(useIntuneStore.getState().filterStatus).toBe("All"); + expect(useIntuneStore.getState().timelineScope.filePath).toBeNull(); + expect(useIntuneStore.getState().selectedEventId).toBeNull(); + expect(screen.queryByText("Type Win32 app")).not.toBeInTheDocument(); + expect(screen.queryByText("Status Failed")).not.toBeInTheDocument(); + }); +}); + +describe("INTUNE-012 New Intune overview triage", () => { + it("shows triage metrics, priority issues, failure patterns, coverage, and correlated event-log jump", () => { + seedReadyResults({ eventLogAnalysis: EVENT_LOG_ANALYSIS }); + render(); + + const metrics = screen.getByRole("region", { name: "Analysis metrics" }); + expect(within(metrics).getByText("Active issues")).toBeInTheDocument(); + expect(within(metrics).getByText("Repeated failures")).toBeInTheDocument(); + expect(within(metrics).getByText("Evidence confidence")).toBeInTheDocument(); + expect(within(metrics).getByText("Dominant source")).toBeInTheDocument(); + expect(within(metrics).getByText("Event log signals")).toBeInTheDocument(); + expect(within(metrics).getByText("Content downloads")).toBeInTheDocument(); + expect(within(metrics).getByText("AppWorkload.log")).toBeInTheDocument(); + + expect(screen.getByText("Priority issues")).toBeInTheDocument(); + expect(screen.getByText("Win32 content download failed")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Show related events" }).length).toBeGreaterThan(0); + expect(screen.getAllByRole("button", { name: "Scope source" }).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Open downloads" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /event log signal/ })).toBeInTheDocument(); + expect(screen.getByText("Failure patterns")).toBeInTheDocument(); + expect(screen.getByText("Source coverage")).toBeInTheDocument(); + expect(screen.getByText("AppWorkload")).toBeInTheDocument(); + expect(screen.getByText("Correlated event log evidence")).toBeInTheDocument(); + expect( + screen.getByText("Intune Management Extension reported a content download failure."), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open downloads" })); + expect(screen.getByText(DOWNLOAD_NAME)).toBeInTheDocument(); + }); +}); + +describe("INTUNE-013 New Intune event-log evidence", () => { + it("filters live event-log rows and jumps back to the correlated IME event", () => { + seedReadyResults({ eventLogAnalysis: EVENT_LOG_ANALYSIS }); + render(); + + fireEvent.click(screen.getByRole("tab", { name: /Event log evidence/ })); + expect(screen.getByText("All channels (1)")).toBeInTheDocument(); + expect(screen.getByText("All severities")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /DeviceManagement-Enterprise-Diagnostics-Provider\/Admin/, + }), + ).toBeInTheDocument(); + expect( + screen.getByText("Intune Management Extension reported a content download failure."), + ).toBeInTheDocument(); + expect(screen.getByText("linked")).toBeInTheDocument(); + + fireEvent.click( + screen.getByText("Intune Management Extension reported a content download failure."), + ); + expect(screen.getByText("Related IME Evidence")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View in Timeline" })); + expect(useIntuneStore.getState().selectedEventId).toBe(1); + expect(screen.getAllByText(FAILED_EVENT_NAME).length).toBeGreaterThan(0); + }); + + it("shows per-channel live query status when no entries return", () => { + seedReadyResults({ eventLogAnalysis: LIVE_EMPTY_EVENT_LOG_ANALYSIS }); + render(); + + fireEvent.click(screen.getByRole("tab", { name: /Event log evidence/ })); + expect(screen.getByText("Live Windows Event Log query completed.")).toBeInTheDocument(); + expect( + screen.getByText("No matching entries were returned from 2 queried channels."), + ).toBeInTheDocument(); + expect(screen.getByText("1 channel query failed.")).toBeInTheDocument(); + expect(screen.getByText("Empty")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText("Access is denied.")).toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/intune/createIntuneOnOpenSource.test.ts b/src/workspaces/intune/createIntuneOnOpenSource.test.ts new file mode 100644 index 000000000..974241054 --- /dev/null +++ b/src/workspaces/intune/createIntuneOnOpenSource.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { analyzeIntuneLogs } from "../../lib/commands"; +import { useUiStore } from "../../stores/ui-store"; +import { createIntuneOnOpenSource } from "./index"; +import { useIntuneStore } from "./intune-store"; +import { + ANALYZED_PATH, + DIAGNOSTIC, + GRAPH_GUID_REGISTRY, + STORY_DOWNLOADS, + STORY_EVENTS, + STORY_SOURCE_FILES, + SUMMARY, +} from "./intune-story-fixtures"; + +vi.mock("../../lib/commands", () => ({ + analyzeIntuneLogs: vi.fn(), +})); + +vi.mock("../../lib/log-source", async () => { + const actual = await vi.importActual( + "../../lib/log-source", + ); + return { + ...actual, + loadLogSource: vi.fn().mockResolvedValue(undefined), + }; +}); + +const analyzeIntuneLogsMock = vi.mocked(analyzeIntuneLogs); + +beforeEach(() => { + useIntuneStore.getState().clear(); + analyzeIntuneLogsMock.mockReset(); + analyzeIntuneLogsMock.mockResolvedValue({ + events: STORY_EVENTS, + downloads: STORY_DOWNLOADS, + summary: SUMMARY, + diagnostics: [DIAGNOSTIC], + sourceFile: ANALYZED_PATH, + sourceFiles: STORY_SOURCE_FILES, + diagnosticsCoverage: { + files: [], + timestampBounds: null, + hasRotatedLogs: false, + dominantSource: null, + }, + diagnosticsConfidence: { level: "Low", score: 0.2, reasons: [] }, + repeatedFailures: [], + evidenceBundle: null, + eventLogAnalysis: null, + guidRegistry: GRAPH_GUID_REGISTRY, + }); +}); + +describe("INTUNE-009 analyzeIntuneLogs Graph option", () => { + function createOnOpen(workspaceId: Parameters[0]) { + const onOpen = createIntuneOnOpenSource(workspaceId); + if (!onOpen) { + throw new Error("createIntuneOnOpenSource must return a handler"); + } + return onOpen; + } + + it("forwards graphApiEnabled and does not include live event logs for a file source", async () => { + useUiStore.setState({ graphApiEnabled: true }); + const onOpen = createOnOpen("intune"); + + await onOpen({ kind: "file", path: "C:/Logs/IME/AppWorkload.log" }, "test.open-file"); + + expect(analyzeIntuneLogsMock).toHaveBeenCalledWith( + "C:/Logs/IME/AppWorkload.log", + expect.any(String), + { includeLiveEventLogs: false, graphApiEnabled: true }, + ); + }); + + it("includes live event logs only for the known windows-intune-ime-logs source", async () => { + useUiStore.setState({ graphApiEnabled: false }); + const onOpen = createOnOpen("new-intune"); + + await onOpen( + { + kind: "known", + sourceId: "windows-intune-ime-logs", + defaultPath: "C:/ProgramData/Microsoft/IntuneManagementExtension/Logs", + pathKind: "folder", + }, + "test.known-source", + ); + + expect(analyzeIntuneLogsMock).toHaveBeenCalledWith( + "C:/ProgramData/Microsoft/IntuneManagementExtension/Logs", + expect.any(String), + { includeLiveEventLogs: true, graphApiEnabled: false }, + ); + }); + + it("excludes live event logs for other known sources", async () => { + useUiStore.setState({ graphApiEnabled: false }); + const onOpen = createOnOpen("intune"); + + await onOpen( + { + kind: "known", + sourceId: "windows-cbs-logs", + defaultPath: "C:/Windows/Logs/CBS", + pathKind: "folder", + }, + "test.known-source", + ); + + expect(analyzeIntuneLogsMock).toHaveBeenCalledWith( + "C:/Windows/Logs/CBS", + expect.any(String), + { includeLiveEventLogs: false, graphApiEnabled: false }, + ); + }); +}); diff --git a/src/workspaces/intune/intune-story-fixtures.ts b/src/workspaces/intune/intune-story-fixtures.ts new file mode 100644 index 000000000..8445209cc --- /dev/null +++ b/src/workspaces/intune/intune-story-fixtures.ts @@ -0,0 +1,293 @@ +import type { EventLogAnalysis } from "../../types/event-log"; +import type { + DownloadStat, + GuidRegistryEntry, + IntuneDiagnosticInsight, + IntuneEvent, + IntuneSummary, +} from "./types"; + +export const APP_GUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; +export const GRAPH_APP_NAME = "Contoso Graph Portal"; +export const FAILED_EVENT_NAME = "Win32 App Install Failed — Contoso Company Portal"; +export const SUCCESS_EVENT_NAME = "Win32 App Detected — Contoso Company Portal"; +export const GUID_ONLY_EVENT_NAME = "Content download retry"; +export const SCRIPT_EVENT_NAME = "PowerShell Script — Inventory Collection"; +export const SCRIPT_BODY = "Write-Output 'Collect inventory'"; +export const DOWNLOAD_NAME = "ContosoCompanyPortal.intunewin"; +export const APPWORKLOAD_PATH = "C:/Logs/IME/AppWorkload.log"; +export const AGENT_EXECUTOR_PATH = "C:/Logs/IME/AgentExecutor.log"; +export const ANALYZED_PATH = "C:/Logs/IME"; + +export const FAILED_EVENT_START = "2026-04-01T19:00:42.578Z"; +export const SUCCESS_EVENT_START = "2026-04-01T18:00:00.000Z"; +export const SCRIPT_EVENT_START = "2026-03-20T12:00:00.000Z"; +export const DOWNLOAD_TIMESTAMP = "2026-04-01T19:00:40.000Z"; + +export const FAILED_EVENT: IntuneEvent = { + id: 1, + eventType: "Win32App", + name: FAILED_EVENT_NAME, + guid: APP_GUID, + status: "Failed", + startTime: FAILED_EVENT_START, + endTime: "2026-04-01T19:01:12.000Z", + durationSecs: 30, + errorCode: "0x87D30067", + detail: [ + `Download failed for app id: ${APP_GUID} with error code = 0x87D30067`, + "", + "AppWorkload context:", + ` L11 2026-04-01T19:00:41.000Z [Win32App][V3Processor] Processing subgraph with app ids: ${APP_GUID}`, + `> L12 2026-04-01T19:00:42.578Z Download failed for app id: ${APP_GUID} with error code = 0x87D30067`, + ].join("\n"), + sourceFile: APPWORKLOAD_PATH, + lineNumber: 12, + startTimeEpoch: Date.parse(FAILED_EVENT_START), + endTimeEpoch: Date.parse("2026-04-01T19:01:12.000Z"), +}; + +export const SUCCESS_EVENT: IntuneEvent = { + id: 2, + eventType: "Win32App", + name: SUCCESS_EVENT_NAME, + guid: APP_GUID, + status: "Success", + startTime: SUCCESS_EVENT_START, + endTime: "2026-04-01T18:00:20.000Z", + durationSecs: 20, + errorCode: null, + detail: "Installed successfully", + sourceFile: APPWORKLOAD_PATH, + lineNumber: 4, + startTimeEpoch: Date.parse(SUCCESS_EVENT_START), + endTimeEpoch: Date.parse("2026-04-01T18:00:20.000Z"), +}; + +export const SCRIPT_EVENT: IntuneEvent = { + id: 3, + eventType: "PowerShellScript", + name: SCRIPT_EVENT_NAME, + guid: "11111111-2222-3333-4444-555555555555", + status: "Success", + startTime: SCRIPT_EVENT_START, + endTime: "2026-03-20T12:00:05.000Z", + durationSecs: 5, + errorCode: null, + detail: "Script completed", + sourceFile: AGENT_EXECUTOR_PATH, + lineNumber: 88, + startTimeEpoch: Date.parse(SCRIPT_EVENT_START), + endTimeEpoch: Date.parse("2026-03-20T12:00:05.000Z"), + scriptBody: SCRIPT_BODY, +}; + +export const GUID_ONLY_EVENT: IntuneEvent = { + id: 5, + eventType: "ContentDownload", + name: GUID_ONLY_EVENT_NAME, + guid: APP_GUID, + status: "Failed", + startTime: "2026-04-01T19:00:41.000Z", + endTime: null, + durationSecs: null, + errorCode: "0x87D30067", + detail: `Download failed for application ${APP_GUID}`, + sourceFile: APPWORKLOAD_PATH, + lineNumber: 11, + startTimeEpoch: Date.parse("2026-04-01T19:00:41.000Z"), + endTimeEpoch: null, +}; + +export const REPEAT_FAILED_EVENT: IntuneEvent = { + ...FAILED_EVENT, + id: 4, + name: FAILED_EVENT_NAME, + startTime: "2026-04-01T19:10:00.000Z", + endTime: "2026-04-01T19:10:20.000Z", + startTimeEpoch: Date.parse("2026-04-01T19:10:00.000Z"), + endTimeEpoch: Date.parse("2026-04-01T19:10:20.000Z"), + lineNumber: 40, +}; + +export const DOWNLOAD: DownloadStat = { + contentId: "content-contoso-portal", + name: DOWNLOAD_NAME, + sizeBytes: 1048576, + speedBps: 524288, + doPercentage: 72.5, + durationSecs: 12.4, + success: true, + timestamp: DOWNLOAD_TIMESTAMP, + timestampEpoch: Date.parse(DOWNLOAD_TIMESTAMP), +}; + +export const SUMMARY: IntuneSummary = { + totalEvents: 4, + win32Apps: 3, + wingetApps: 0, + scripts: 1, + remediations: 0, + succeeded: 2, + failed: 2, + inProgress: 0, + pending: 0, + timedOut: 0, + totalDownloads: 1, + successfulDownloads: 1, + failedDownloads: 0, + failedScripts: 0, + logTimeSpan: "Mar 20 – Apr 1", +}; + +export const DIAGNOSTIC: IntuneDiagnosticInsight = { + id: "diag-download-fail", + severity: "Error", + category: "Download", + remediationPriority: "Immediate", + title: "Win32 content download failed", + summary: "Contoso Company Portal failed to retrieve content from Delivery Optimization.", + likelyCause: "Content location or DO peering failed before install started.", + evidence: [ + "AppWorkload reported 0x87D30067 for Contoso Company Portal", + "Download row ContosoCompanyPortal.intunewin completed after the failure", + ], + nextChecks: ["Confirm the content URI is reachable", "Review DO service health"], + suggestedFixes: ["Retry the app assignment after confirming content availability"], + focusAreas: ["Download", "Install"], + affectedSourceFiles: [APPWORKLOAD_PATH], + relatedErrorCodes: ["0x87D30067"], +}; + +export const GRAPH_GUID_REGISTRY: Record = { + [APP_GUID]: { + name: GRAPH_APP_NAME, + source: "GraphApi", + category: "app", + publisher: "Contoso", + }, +}; + +export const EVENT_LOG_ANALYSIS: EventLogAnalysis = { + sourceKind: "Live", + entries: [ + { + id: 501, + channel: "DeviceManagementAdmin", + channelDisplay: "DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + provider: "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider", + eventId: 404, + severity: "Error", + timestamp: FAILED_EVENT_START, + computer: "WORKSTATION01", + message: "Intune Management Extension reported a content download failure.", + correlationActivityId: null, + sourceFile: "live://DeviceManagementAdmin", + }, + ], + channelSummaries: [ + { + channel: "DeviceManagementAdmin", + channelDisplay: "DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + entryCount: 1, + errorCount: 1, + warningCount: 0, + timestampBounds: { + firstTimestamp: FAILED_EVENT_START, + lastTimestamp: FAILED_EVENT_START, + }, + sourceFile: "live://DeviceManagementAdmin", + }, + ], + correlationLinks: [ + { + eventLogEntryId: 501, + linkedIntuneEventId: 1, + linkedDiagnosticId: DIAGNOSTIC.id, + correlationKind: "ErrorCodeMatch", + timeDeltaSecs: 2, + }, + ], + parsedFileCount: 1, + totalEntryCount: 1, + errorEntryCount: 1, + warningEntryCount: 0, + timestampBounds: { + firstTimestamp: FAILED_EVENT_START, + lastTimestamp: FAILED_EVENT_START, + }, + liveQuery: { + attemptedChannelCount: 2, + successfulChannelCount: 2, + channelsWithResultsCount: 1, + failedChannelCount: 0, + perChannelEntryLimit: 200, + channels: [ + { + channel: "DeviceManagementAdmin", + channelDisplay: "DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + channelPath: "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + sourceFile: "live://DeviceManagementAdmin", + status: "Success", + entryCount: 1, + errorMessage: null, + }, + { + channel: "Autopilot", + channelDisplay: "Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin", + channelPath: "Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin", + sourceFile: "live://Autopilot", + status: "Empty", + entryCount: 0, + errorMessage: null, + }, + ], + }, +}; + +export const LIVE_EMPTY_EVENT_LOG_ANALYSIS: EventLogAnalysis = { + ...EVENT_LOG_ANALYSIS, + entries: [], + channelSummaries: [], + correlationLinks: [], + totalEntryCount: 0, + errorEntryCount: 0, + parsedFileCount: 0, + timestampBounds: null, + liveQuery: { + attemptedChannelCount: 2, + successfulChannelCount: 1, + channelsWithResultsCount: 0, + failedChannelCount: 1, + perChannelEntryLimit: 200, + channels: [ + { + channel: "DeviceManagementAdmin", + channelDisplay: "DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + channelPath: "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + sourceFile: "live://DeviceManagementAdmin", + status: "Empty", + entryCount: 0, + errorMessage: null, + }, + { + channel: "Autopilot", + channelDisplay: "Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin", + channelPath: "Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin", + sourceFile: "live://Autopilot", + status: "Failed", + entryCount: 0, + errorMessage: "Access is denied.", + }, + ], + }, +}; + +export const STORY_EVENTS = [ + FAILED_EVENT, + SUCCESS_EVENT, + SCRIPT_EVENT, + REPEAT_FAILED_EVENT, +]; +export const STORY_DOWNLOADS = [DOWNLOAD]; +export const STORY_SOURCE_FILES = [APPWORKLOAD_PATH, AGENT_EXECUTOR_PATH]; diff --git a/src/workspaces/macos-diag/MacosDiagWorkspace.test.tsx b/src/workspaces/macos-diag/MacosDiagWorkspace.test.tsx new file mode 100644 index 000000000..f49897772 --- /dev/null +++ b/src/workspaces/macos-diag/MacosDiagWorkspace.test.tsx @@ -0,0 +1,256 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MacosDiagWorkspace } from "./MacosDiagWorkspace"; +import { useMacosDiagStore } from "./macos-diag-store"; +import type { + MacosDefenderResult, + MacosDiagEnvironment, + MacosIntuneLogScanResult, + MacosPackagesResult, + MacosProfilesResult, + MacosUnifiedLogResult, +} from "./types"; + +vi.mock("../../lib/commands", () => ({ + macosScanEnvironment: vi.fn(), + macosScanIntuneLogs: vi.fn(), + macosListProfiles: vi.fn(), + macosInspectDefender: vi.fn(), + macosListPackages: vi.fn(), + macosGetPackageInfo: vi.fn(), + macosGetPackageFiles: vi.fn(), + macosQueryUnifiedLog: vi.fn(), + openLogFile: vi.fn(), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 28, + size: 28, + })), + getTotalSize: () => count * 28, + measureElement: vi.fn(), + scrollToIndex: vi.fn(), + }), +})); + +function environment( + fullDiskAccess: MacosDiagEnvironment["fullDiskAccess"] = "granted", +): MacosDiagEnvironment { + return { + macosVersion: "15.3", + macosBuild: "24D70", + fullDiskAccess, + tools: { + profiles: true, + mdatp: true, + pkgutil: true, + logCommand: true, + }, + directories: { + intuneSystemLogs: true, + intuneUserLogs: true, + companyPortalLogs: true, + intuneScriptsLogs: true, + defenderLogs: true, + defenderDiag: true, + }, + summary: "macOS diagnostics ready", + }; +} + +function intuneLogs(): MacosIntuneLogScanResult { + return { + files: [ + { + path: "/Library/Logs/Microsoft/Intune/IntuneMDMDaemon.log", + fileName: "IntuneMDMDaemon.log", + sizeBytes: 2048, + modifiedUnixMs: Date.parse("2026-01-15T12:00:00.000Z"), + sourceDirectory: "/Library/Logs/Microsoft/Intune", + }, + ], + scannedDirectories: ["/Library/Logs/Microsoft/Intune"], + totalSizeBytes: 2048, + }; +} + +function profiles(): MacosProfilesResult { + return { + profiles: [ + { + profileIdentifier: "com.contoso.mdm", + profileDisplayName: "Contoso MDM", + profileOrganization: "Contoso", + profileType: "Configuration", + profileUuid: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + installDate: null, + payloads: [], + isManaged: true, + verificationState: null, + description: null, + source: null, + removalDisallowed: null, + }, + ], + enrollmentStatus: { + enrolled: true, + mdmServer: "https://manage.microsoft.com", + enrollmentType: "Device", + rawOutput: "", + }, + rawOutput: "", + }; +} + +function defender(): MacosDefenderResult { + return { + health: { + healthy: true, + healthIssues: [], + realTimeProtectionEnabled: true, + definitionsStatus: "Up to date", + engineVersion: "1.1", + appVersion: "101.25012.0", + rawOutput: "", + }, + logFiles: [], + diagFiles: [], + }; +} + +function packages(): MacosPackagesResult { + return { + packages: [ + { + packageId: "com.microsoft.wdav", + version: "101.25012.0", + volume: "/", + location: null, + installTime: "1700000000", + }, + ], + totalCount: 1, + microsoftCount: 1, + }; +} + +function unifiedLog(): MacosUnifiedLogResult { + return { + entries: [ + { + timestamp: "2026-01-15T12:00:00.000Z", + process: "mdmclient", + subsystem: "com.apple.ManagedClient", + category: "mdm", + level: "info", + message: "MDM check-in completed", + pid: 100, + tid: 1, + }, + ], + totalMatched: 1, + capped: false, + resultCap: 5000, + predicateUsed: "process == \"mdmclient\"", + timeRange: null, + }; +} + +function seedReadyEnvironment() { + useMacosDiagStore.setState({ + environment: environment("granted"), + environmentPhase: "ready", + environmentError: null, + intuneLogScan: intuneLogs(), + intuneLogScanLoading: false, + profilesResult: profiles(), + profilesLoading: false, + defenderResult: defender(), + defenderLoading: false, + packagesResult: packages(), + packagesLoading: false, + unifiedLogResult: unifiedLog(), + unifiedLogLoading: false, + activeTab: "intune-logs", + }); +} + +afterEach(() => { + cleanup(); + useMacosDiagStore.getState().clear(); +}); + +beforeEach(() => { + useMacosDiagStore.getState().clear(); +}); + +describe("MacosDiagWorkspace fixtures", () => { + it("MACDIAG-001 shows the FDA gate when Full Disk Access is not granted", () => { + useMacosDiagStore.setState({ + environment: environment("notGranted"), + environmentPhase: "ready", + environmentError: null, + }); + render(); + + expect(screen.getByText("Full Disk Access Required")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Re-check FDA status" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Open System Settings..." }), + ).toBeInTheDocument(); + }); + + it("MACDIAG-001 shows the ready banner with version, FDA pill, tools, and Refresh all", () => { + seedReadyEnvironment(); + render(); + + expect(screen.getByText("macOS Diagnostics")).toBeInTheDocument(); + expect(screen.getByText("macOS 15.3 (24D70)")).toBeInTheDocument(); + expect(screen.getByText("Full Disk Access")).toBeInTheDocument(); + expect(screen.getByText("profiles")).toBeInTheDocument(); + expect(screen.getByText("mdatp")).toBeInTheDocument(); + expect(screen.getByText("pkgutil")).toBeInTheDocument(); + expect(screen.getByText("log")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh all" })).toBeInTheDocument(); + }); + + it("MACDIAG-002 shows Intune, Profiles, Defender, Packages, and Unified Log tabs", () => { + seedReadyEnvironment(); + render(); + + expect(screen.getByRole("button", { name: /Intune Logs/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Profiles & MDM/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Defender/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Packages/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Unified Log/ })).toBeInTheDocument(); + + expect(screen.getByText("Discovered Log Files")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open in log viewer" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Profiles & MDM/ })); + expect(screen.getByText(/Installed Configuration Profiles/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy all" })).toBeInTheDocument(); + expect(screen.getByText(/Enrolled via Device/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Defender/ })); + expect(screen.getByText("Defender Health: OK")).toBeInTheDocument(); + expect(screen.getByText("Real-time Protection")).toBeInTheDocument(); + expect(screen.getByText("Definitions")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Packages/ })); + expect(screen.getByText("Microsoft Packages")).toBeInTheDocument(); + expect(screen.getAllByText("com.microsoft.wdav").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: /Unified Log/ })); + expect(screen.getByText("Hide NSURLSession noise")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Run Query" })).toBeInTheDocument(); + expect(screen.getByText("MDM Client (mdmclient)")).toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/secureboot/SecureBootWorkspace.test.tsx b/src/workspaces/secureboot/SecureBootWorkspace.test.tsx new file mode 100644 index 000000000..04f53fb03 --- /dev/null +++ b/src/workspaces/secureboot/SecureBootWorkspace.test.tsx @@ -0,0 +1,116 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SecureBootWorkspace } from "./SecureBootWorkspace"; +import { useSecureBootStore } from "./secureboot-store"; +import { useUiStore } from "../../stores/ui-store"; +import type { SecureBootAnalysisResult, SecureBootScanState } from "./types"; + +function scanState(): SecureBootScanState { + return { + secureBootEnabled: true, + managedOptIn: 1, + availableUpdates: null, + uefiCa2023Capable: 1, + uefiCa2023Status: 0, + uefiCa2023Error: null, + managedOptInDate: null, + telemetryLevel: null, + diagtrackRunning: null, + diagtrackStartType: null, + tpmPresent: true, + tpmEnabled: true, + tpmActivated: null, + tpmSpecVersion: null, + bitlockerProtectionOn: true, + bitlockerEncryptionStatus: null, + bitlockerKeyProtectors: [], + diskPartitionStyle: "GPT", + payloadFolderExists: null, + payloadBinCount: null, + scheduledTaskExists: null, + scheduledTaskLastRun: null, + scheduledTaskLastResult: null, + wincsAvailable: null, + pendingRebootSources: [], + deviceName: null, + osCaption: null, + osBuild: null, + oemManufacturer: null, + oemModel: null, + firmwareVersion: null, + firmwareDate: null, + rawRegistryDump: "HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot", + }; +} + +function analysis(): SecureBootAnalysisResult { + return { + stage: "stage5", + dataSource: "liveScan", + scanState: scanState(), + sessions: [], + timeline: [ + { + timestamp: "2026-01-15T12:00:00.000Z", + source: "detect", + level: "info", + eventType: "sessionStart", + message: "Secure Boot detection started", + stage: "stage5", + errorCode: null, + }, + ], + diagnostics: [ + { + ruleId: "SB-COMPLIANT", + severity: "info", + title: "UEFI CA 2023 is active", + detail: "The 2023 certificate is present and Secure Boot is enabled.", + recommendation: "No action required.", + }, + ], + scriptResult: null, + }; +} + +afterEach(() => { + cleanup(); + useSecureBootStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); +}); + +beforeEach(() => { + useSecureBootStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); + useUiStore.setState({ currentPlatform: "windows" }); +}); + +describe("SecureBootWorkspace fixtures", () => { + it("SB-002 shows diagnostics, timeline columns, and raw dump copy", () => { + useSecureBootStore.getState().setResult(analysis()); + render(); + + expect(screen.getByRole("button", { name: /Diagnostics/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Timeline/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Raw Data" })).toBeInTheDocument(); + + expect(screen.getByText("SB-COMPLIANT")).toBeInTheDocument(); + expect(screen.getByText("UEFI CA 2023 is active")).toBeInTheDocument(); + expect( + screen.getByText("The 2023 certificate is present and Secure Boot is enabled."), + ).toBeInTheDocument(); + expect(screen.getByText("No action required.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Timeline/ })); + expect(screen.getByText("Timestamp")).toBeInTheDocument(); + expect(screen.getByText("Source")).toBeInTheDocument(); + expect(screen.getByText("Message")).toBeInTheDocument(); + expect(screen.getByText("Secure Boot detection started")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Raw Data" })); + expect( + screen.getByText("HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/sysmon/SysmonWorkspace.test.tsx b/src/workspaces/sysmon/SysmonWorkspace.test.tsx new file mode 100644 index 000000000..cc18e79cc --- /dev/null +++ b/src/workspaces/sysmon/SysmonWorkspace.test.tsx @@ -0,0 +1,147 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SysmonWorkspace } from "./SysmonWorkspace"; +import { useSysmonStore } from "./sysmon-store"; +import { useUiStore } from "../../stores/ui-store"; +import type { SysmonAnalysisResult, SysmonEvent } from "./types"; + +vi.mock("../../hooks/use-app-actions", () => ({ + useAppActions: () => ({ + commandState: { canRefresh: false }, + refreshActiveSource: vi.fn(), + }), +})); + +vi.mock("../../lib/commands", () => ({ + analyzeSysmonLogs: vi.fn(), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 28, + size: 28, + })), + getTotalSize: () => count * 28, + measureElement: vi.fn(), + scrollToIndex: vi.fn(), + }), +})); + +function event(): SysmonEvent { + return { + id: 1, + eventId: 1, + eventType: "ProcessCreate", + eventTypeDisplay: "Process Create", + severity: "Info", + timestamp: "2026-01-15T12:00:00.000Z", + timestampMs: Date.parse("2026-01-15T12:00:00.000Z"), + computer: "PC01", + recordId: 1, + image: "C:\\Windows\\System32\\cmd.exe", + message: "Process Create", + sourceFile: "Sysmon.evtx", + }; +} + +function analysis(): SysmonAnalysisResult { + return { + events: [event()], + summary: { + totalEvents: 1, + eventTypeCounts: [], + uniqueProcesses: 1, + uniqueComputers: 1, + earliestTimestamp: null, + latestTimestamp: null, + sourceFiles: ["Sysmon.evtx"], + parseErrors: 0, + }, + config: { + schemaVersion: "4.90", + hashAlgorithms: "SHA256", + found: true, + lastConfigChange: null, + configurationXml: "", + sysmonVersion: "15.14", + activeEventTypes: [], + }, + dashboard: { + timelineMinute: [], + timelineHourly: [], + timelineDaily: [], + topProcesses: [], + topDestinations: [], + topPorts: [], + topDnsQueries: [], + securityEvents: { + totalWarnings: 0, + totalErrors: 0, + eventsByType: [], + }, + topTargetFiles: [], + topRegistryKeys: [], + }, + sourcePath: "C:\\temp\\Sysmon.evtx", + }; +} + +afterEach(() => { + cleanup(); + useSysmonStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); +}); + +beforeEach(() => { + useSysmonStore.getState().clear(); + useUiStore.setState(useUiStore.getInitialState(), true); + useUiStore.setState({ currentPlatform: "windows" }); +}); + +describe("SysmonWorkspace fixtures", () => { + it("SYSMON-001 shows the empty analyze state then dashboard after a result", () => { + render(); + + expect(screen.getByText("Sysmon Log Viewer")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open .evtx files..." })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "This computer" })).toBeInTheDocument(); + + cleanup(); + useSysmonStore.getState().setResults(analysis()); + render(); + + expect(screen.getByRole("tab", { name: "Dashboard" })).toBeInTheDocument(); + expect(screen.getByText("Total Events")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + }); + + it("SYSMON-002 switches Dashboard, Events, Summary, and Configuration", () => { + useSysmonStore.getState().setResults(analysis()); + render(); + + expect(screen.getByRole("tab", { name: "Dashboard" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByText("Total Events")).toBeInTheDocument(); + expect(screen.getByText("Security Alerts")).toBeInTheDocument(); + expect(screen.getByText("Top Processes")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: /Events/ })); + expect(screen.getByLabelText("Search events")).toBeInTheDocument(); + expect(screen.getByText(/Type:/)).toBeInTheDocument(); + expect(screen.getByText(/Severity:/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Summary" })); + expect(screen.getByText("Sysmon Analysis Summary")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Configuration" })); + expect(screen.getByText("Sysmon Configuration")).toBeInTheDocument(); + expect(screen.getByText("Schema Version")).toBeInTheDocument(); + expect(screen.getByText("Configuration Details (from Event ID 16)")).toBeInTheDocument(); + }); +}); diff --git a/src/workspaces/timeline/index.ts b/src/workspaces/timeline/index.ts index 51bd35080..d5031afe1 100644 --- a/src/workspaces/timeline/index.ts +++ b/src/workspaces/timeline/index.ts @@ -1,5 +1,6 @@ // src/workspaces/timeline/index.ts import { lazy } from "react"; +import { useUiStore } from "../../stores/ui-store"; import type { WorkspaceDefinition } from "../types"; export const timelineWorkspace: WorkspaceDefinition = { @@ -21,6 +22,24 @@ export const timelineWorkspace: WorkspaceDefinition = { { name: "All Files", extensions: ["*"] }, ], actionLabels: { + file: "Open timeline file...", + folder: "Open timeline folder...", placeholder: "Open timeline source...", }, + onOpenSource: async (source, trigger) => { + useUiStore.getState().ensureWorkspaceVisible("timeline", trigger); + try { + const { openTimelineSource } = await import("./open-timeline-source"); + await openTimelineSource(source); + } catch (error) { + console.error("[timeline] failed to open source", { + source, + trigger, + error, + }); + if (trigger === "drag-drop.path-open") { + throw error; + } + } + }, }; diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts new file mode 100644 index 000000000..73b1748e2 --- /dev/null +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -0,0 +1,343 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deferred } from "../../test-utils/deferred"; +import { listLogFolder } from "../../lib/commands"; +import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; +import { useTimelineStore } from "../../stores/timeline-store"; +import type { TimelineBundle } from "../../types/timeline"; +import { + openTimelineFiles, + openTimelineSource, + replaceTimelineSource, +} from "./open-timeline-source"; +vi.mock("../../lib/commands", () => ({ + listLogFolder: vi.fn(), +})); + +vi.mock("../../components/timeline/hooks/useTimelineBundle", () => ({ + buildTimelineFromSources: vi.fn(async () => ({ sources: [] })), +})); + + +function bundleFor(paths: string[]): TimelineBundle { + return { + id: "fixture", + sources: paths.map((path, idx) => ({ + idx, + kind: "intuneEvents", + path, + displayName: path, + color: "#000000", + entryCount: 0, + })), + timeRangeMs: [0, 0], + totalEntries: 0, + incidents: [], + deniedGuids: [], + errors: [], + tunables: { + overlapWindowMs: 5_000, + minSourceCount: 2, + maxIncidentSpanMs: 60_000, + enabledSignalKinds: ["errorSeverity"], + }, + }; +} + +describe("openTimelineSource", () => { + beforeEach(() => { + vi.clearAllMocks(); + useTimelineStore.setState({ bundle: null, loadError: null }); + }); + + it("builds a timeline from a single file", async () => { + await openTimelineSource({ kind: "file", path: "/tmp/AppEnforce.log" }); + expect(buildTimelineFromSources).toHaveBeenCalledWith([ + { path: "/tmp/AppEnforce.log" }, + ]); + }); + it("builds a timeline from a known file source", async () => { + const defaultPath = "/tmp/known.log"; + + await openTimelineSource({ + kind: "known", + sourceId: "known-file", + defaultPath, + pathKind: "file", + }); + + expect(buildTimelineFromSources).toHaveBeenCalledWith([ + { path: defaultPath }, + ]); + expect(listLogFolder).not.toHaveBeenCalled(); + }); + + it("expands a known folder source before building", async () => { + const defaultPath = "/tmp/known-logs"; + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "known", + source: { + kind: "known", + sourceId: "known-folder", + defaultPath, + pathKind: "folder", + }, + entries: [ + { + name: "known.log", + path: `${defaultPath}/known.log`, + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + }); + + await openTimelineSource({ + kind: "known", + sourceId: "known-folder", + defaultPath, + pathKind: "folder", + }); + + expect(listLogFolder).toHaveBeenCalledWith(defaultPath); + expect(buildTimelineFromSources).toHaveBeenCalledWith([ + { path: `${defaultPath}/known.log` }, + ]); + }); + + it("appends raw dropped paths and deduplicates existing sources", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log", "/tmp/other.log"]), + }); + + await openTimelineFiles([ + "/tmp/dropped.log", + "/tmp/other.log", + "/tmp/dropped.log", + ]); + + expect(buildTimelineFromSources).toHaveBeenCalledWith([ + { path: "/tmp/existing.log" }, + { path: "/tmp/other.log" }, + { path: "/tmp/dropped.log" }, + ]); + }); + + it("does not build a timeline for an empty raw path array", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log"]), + }); + + await openTimelineFiles([]); + + expect(buildTimelineFromSources).not.toHaveBeenCalled(); + }); + + it("unions folder files with an existing timeline", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log"]), + }); + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/logs" }, + entries: [ + { + name: "a.log", + path: "/tmp/logs/a.log", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + { + name: "dir", + path: "/tmp/logs/dir", + isDir: true, + sizeBytes: null, + modifiedUnixMs: null, + }, + ], + }); + + await openTimelineSource({ kind: "folder", path: "/tmp/logs" }); + expect(buildTimelineFromSources).toHaveBeenCalledWith([ + { path: "/tmp/existing.log" }, + { path: "/tmp/logs/a.log" }, + ]); + }); + it("replaces pending timeline appends instead of merging stale sources", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log"]), + }); + const firstBuild = deferred(); + const secondBuild = deferred(); + vi.mocked(buildTimelineFromSources) + .mockImplementationOnce(async () => { + const bundle = await firstBuild.promise; + useTimelineStore.getState().setBundle(bundle); + return bundle; + }) + .mockImplementationOnce(async () => { + const bundle = await secondBuild.promise; + useTimelineStore.getState().setBundle(bundle); + return bundle; + }); + + const append = openTimelineSource({ kind: "file", path: "/tmp/old.log" }); + await vi.waitFor(() => { + expect(buildTimelineFromSources).toHaveBeenCalledTimes(1); + }); + const replacement = replaceTimelineSource({ + kind: "file", + path: "/tmp/new.log", + }); + + firstBuild.resolve(bundleFor(["/tmp/existing.log", "/tmp/old.log"])); + await vi.waitFor(() => { + expect(buildTimelineFromSources).toHaveBeenCalledTimes(2); + }); + expect(buildTimelineFromSources).toHaveBeenNthCalledWith(2, [ + { path: "/tmp/new.log" }, + ]); + + secondBuild.resolve(bundleFor(["/tmp/new.log"])); + await Promise.all([append, replacement]); + }); + it("clears the current timeline for an empty replacement folder", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log"]), + }); + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/empty-replacement" }, + entries: [], + }); + + await replaceTimelineSource({ + kind: "folder", + path: "/tmp/empty-replacement", + }); + + expect(useTimelineStore.getState().bundle).toBeNull(); + expect(buildTimelineFromSources).not.toHaveBeenCalled(); + }); + + it("clears the current timeline before a replacement load failure", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log"]), + }); + vi.mocked(listLogFolder).mockRejectedValue(new Error("access denied")); + + await expect( + replaceTimelineSource({ kind: "folder", path: "/tmp/denied" }), + ).rejects.toThrow("access denied"); + + expect(useTimelineStore.getState().bundle).toBeNull(); + expect(useTimelineStore.getState().loadError).toBe("access denied"); + }); + + it("adds the folder itself when IME logs are present", async () => { + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/ime" }, + entries: [ + { + name: "IntuneManagementExtension.log", + path: "/tmp/ime/IntuneManagementExtension.log", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + { + name: "AgentExecutor.log", + path: "/tmp/ime/AgentExecutor.log", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + }); + + await openTimelineSource({ kind: "folder", path: "/tmp/ime" }); + expect(buildTimelineFromSources).toHaveBeenCalledWith([ + { path: "/tmp/ime/IntuneManagementExtension.log" }, + { path: "/tmp/ime/AgentExecutor.log" }, + { path: "/tmp/ime" }, + ]); + }); + + it("does not treat an empty folder as an IntuneEvents source", async () => { + useTimelineStore.setState({ + bundle: bundleFor(["/tmp/existing.log"]), + loadError: "stale error", + }); + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/empty" }, + entries: [], + }); + + await openTimelineSource({ kind: "folder", path: "/tmp/empty" }); + expect(buildTimelineFromSources).not.toHaveBeenCalled(); + expect(useTimelineStore.getState().loadError).toBeNull(); + }); + it("serializes overlapping opens so later files are not lost", async () => { + const listing = deferred>>(); + const firstBuild = deferred(); + const secondBuild = deferred(); + const folderSource = { kind: "folder" as const, path: "/tmp/logs" }; + const fileSource = { kind: "file" as const, path: "/tmp/other.log" }; + + vi.mocked(listLogFolder).mockReturnValueOnce(listing.promise); + vi.mocked(buildTimelineFromSources) + .mockImplementationOnce(async () => { + const bundle = await firstBuild.promise; + useTimelineStore.getState().setBundle(bundle); + return bundle; + }) + .mockImplementationOnce(async () => { + const bundle = await secondBuild.promise; + useTimelineStore.getState().setBundle(bundle); + return bundle; + }); + + const folderOpen = openTimelineSource(folderSource); + const fileOpen = openTimelineSource(fileSource); + + listing.resolve({ + sourceKind: "folder", + source: folderSource, + entries: [ + { + name: "a.log", + path: "/tmp/logs/a.log", + isDir: false, + sizeBytes: 1, + modifiedUnixMs: null, + }, + ], + }); + + await vi.waitFor(() => { + expect(buildTimelineFromSources).toHaveBeenCalledTimes(1); + }); + expect(buildTimelineFromSources).toHaveBeenNthCalledWith(1, [ + { path: "/tmp/logs/a.log" }, + ]); + + firstBuild.resolve(bundleFor(["/tmp/logs/a.log"])); + await vi.waitFor(() => { + expect(buildTimelineFromSources).toHaveBeenCalledTimes(2); + }); + expect(buildTimelineFromSources).toHaveBeenNthCalledWith(2, [ + { path: "/tmp/logs/a.log" }, + { path: "/tmp/other.log" }, + ]); + + secondBuild.resolve(bundleFor(["/tmp/logs/a.log", "/tmp/other.log"])); + await Promise.all([folderOpen, fileOpen]); + + expect( + useTimelineStore.getState().bundle?.sources.map((s) => s.path), + ).toEqual(["/tmp/logs/a.log", "/tmp/other.log"]); + }); +}); diff --git a/src/workspaces/timeline/open-timeline-source.ts b/src/workspaces/timeline/open-timeline-source.ts new file mode 100644 index 000000000..7c3c46e82 --- /dev/null +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -0,0 +1,98 @@ +import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; +import { listLogFolder } from "../../lib/commands"; +import { useTimelineStore } from "../../stores/timeline-store"; +import type { FolderEntry, LogSource } from "../../types/log"; + +function incomingFromListing( + folderPath: string, + entries: FolderEntry[], +): string[] { + const childPaths = entries + .filter((entry) => !entry.isDir) + .map((entry) => entry.path); + if (childPaths.length === 0) { + return []; + } + const hasIme = childPaths.some((path) => { + const lower = path.toLowerCase(); + return ( + lower.endsWith("agentexecutor.log") || + lower.endsWith("intunemanagementextension.log") + ); + }); + return hasIme ? [...childPaths, folderPath] : childPaths; +} + +let timelineOpenQueue: Promise = Promise.resolve(); + +function enqueueTimelineOpen(operation: () => Promise): Promise { + const queued = timelineOpenQueue.then(() => { + useTimelineStore.getState().setLoadError(null); + return operation(); + }); + timelineOpenQueue = queued.catch((error) => { + useTimelineStore + .getState() + .setLoadError(error instanceof Error ? error.message : String(error)); + }); + return queued; +} + +async function appendTimelineSources(incoming: string[]): Promise { + if (incoming.length === 0) { + return; + } + + const existing = + useTimelineStore.getState().bundle?.sources.map((item) => item.path) ?? []; + const merged = Array.from(new Set([...existing, ...incoming])).map( + (path) => ({ + path, + }), + ); + await buildTimelineFromSources(merged); +} + +async function replaceTimelineSources(incoming: string[]): Promise { + if (incoming.length === 0) { + return; + } + + const sources = Array.from(new Set(incoming)).map((path) => ({ path })); + await buildTimelineFromSources(sources); +} + +async function incomingFromSource(source: LogSource): Promise { + if (source.kind === "file") { + return [source.path]; + } + + if (source.kind === "folder") { + const listing = await listLogFolder(source.path); + return incomingFromListing(source.path, listing.entries); + } + + if (source.pathKind === "file") { + return [source.defaultPath]; + } + + const listing = await listLogFolder(source.defaultPath); + return incomingFromListing(source.defaultPath, listing.entries); +} + +export function openTimelineSource(source: LogSource): Promise { + return enqueueTimelineOpen(async () => { + await appendTimelineSources(await incomingFromSource(source)); + }); +} + +export function replaceTimelineSource(source: LogSource): Promise { + return enqueueTimelineOpen(async () => { + useTimelineStore.getState().setBundle(null); + await replaceTimelineSources(await incomingFromSource(source)); + }); +} + +export function openTimelineFiles(paths: string[]): Promise { + return enqueueTimelineOpen(() => appendTimelineSources(paths)); +}