From 032dcf5293e7798c5bf8f5cdcbaad46c6080308e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 11:48:17 -0400 Subject: [PATCH 01/30] docs(qa): add canonical user-story tracker and dialog landmarks Inventory every chrome and workspace feature in docs/qa/user-stories.csv with expected behavior from the current code. Add fixture/RTL coverage for those surfaces, wire Event Log and Timeline File > Open through lazy workspace handlers, and expose dialog landmarks on Filter, Collect Diagnostics, Collection Complete, Update, and the first-run file-association prompt. --- CHANGELOG.md | 4 + docs/qa/user-stories.csv | 119 +++++ src/components/dialogs/AboutDialog.test.tsx | 8 + src/components/dialogs/AboutDialog.tsx | 3 + .../dialogs/CollectDiagnosticsDialog.test.tsx | 101 +++++ .../dialogs/CollectDiagnosticsDialog.tsx | 3 + .../dialogs/CollectionCompleteDialog.tsx | 3 + .../dialogs/EvidenceBundleDialog.test.tsx | 175 ++++++++ .../dialogs/FileAssociationPromptDialog.tsx | 3 + src/components/dialogs/FilterDialog.test.tsx | 19 + src/components/dialogs/FilterDialog.tsx | 3 + .../dialogs/SettingsDialog.test.tsx | 51 ++- src/components/dialogs/UpdateDialog.test.tsx | 145 ++++++ src/components/dialogs/UpdateDialog.tsx | 7 +- .../dialogs/settings/AppearanceTab.test.tsx | 103 +++++ .../dialogs/settings/BehaviorTab.test.tsx | 41 ++ .../dialogs/settings/ColumnsTab.test.tsx | 49 ++ .../settings/FileAssociationsTab.test.tsx | 55 +++ .../dialogs/settings/GraphApiTab.test.tsx | 8 + .../dialogs/settings/UpdatesTab.test.tsx | 17 +- .../layout/StatusBar.folder-progress.test.tsx | 47 ++ .../layout/Toolbar.dsregcmd.test.tsx | 90 ++++ .../log-view/DnsWorkspaceBanner.test.tsx | 83 ++++ .../log-view/LogListView.selection.test.tsx | 145 ++++++ .../log-view/LogRow.stories.test.tsx | 108 +++++ .../log-view/MergeLegendBar.test.tsx | 74 ++++ .../log-view/SectionDividerRow.test.tsx | 56 +++ .../registry-view/RegistryViewer.test.tsx | 71 +++ src/hooks/use-app-actions.ts | 26 +- src/hooks/use-app-menu.test.tsx | 21 + src/hooks/use-context-menu.test.ts | 99 +++++ src/hooks/use-drag-drop.test.tsx | 150 +++++++ src/hooks/use-file-association.test.tsx | 29 +- .../deployment/DeploymentWorkspace.test.tsx | 131 ++++++ .../dsregcmd/DsregcmdWorkspace.test.tsx | 417 ++++++++++++++++++ .../event-log/EventLogWorkspace.test.tsx | 142 ++++++ src/workspaces/event-log/index.ts | 7 + .../event-log/open-event-log-source.test.ts | 78 ++++ .../event-log/open-event-log-source.ts | 42 ++ .../intune/IntuneDashboard.stories.test.tsx | 377 ++++++++++++++++ .../NewIntuneWorkspace.stories.test.tsx | 226 ++++++++++ .../intune/createIntuneOnOpenSource.test.ts | 90 ++++ .../intune/intune-story-fixtures.ts | 292 ++++++++++++ .../macos-diag/MacosDiagWorkspace.test.tsx | 256 +++++++++++ .../secureboot/SecureBootWorkspace.test.tsx | 112 +++++ .../sysmon/SysmonWorkspace.test.tsx | 143 ++++++ src/workspaces/timeline/index.ts | 8 + .../timeline/open-timeline-source.test.ts | 47 ++ .../timeline/open-timeline-source.ts | 33 ++ 49 files changed, 4284 insertions(+), 33 deletions(-) create mode 100644 docs/qa/user-stories.csv create mode 100644 src/components/dialogs/CollectDiagnosticsDialog.test.tsx create mode 100644 src/components/dialogs/EvidenceBundleDialog.test.tsx create mode 100644 src/components/dialogs/FilterDialog.test.tsx create mode 100644 src/components/dialogs/UpdateDialog.test.tsx create mode 100644 src/components/dialogs/settings/AppearanceTab.test.tsx create mode 100644 src/components/dialogs/settings/BehaviorTab.test.tsx create mode 100644 src/components/dialogs/settings/ColumnsTab.test.tsx create mode 100644 src/components/dialogs/settings/FileAssociationsTab.test.tsx create mode 100644 src/components/layout/StatusBar.folder-progress.test.tsx create mode 100644 src/components/layout/Toolbar.dsregcmd.test.tsx create mode 100644 src/components/log-view/DnsWorkspaceBanner.test.tsx create mode 100644 src/components/log-view/LogListView.selection.test.tsx create mode 100644 src/components/log-view/LogRow.stories.test.tsx create mode 100644 src/components/log-view/MergeLegendBar.test.tsx create mode 100644 src/components/log-view/SectionDividerRow.test.tsx create mode 100644 src/components/registry-view/RegistryViewer.test.tsx create mode 100644 src/hooks/use-context-menu.test.ts create mode 100644 src/hooks/use-drag-drop.test.tsx create mode 100644 src/workspaces/deployment/DeploymentWorkspace.test.tsx create mode 100644 src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx create mode 100644 src/workspaces/event-log/EventLogWorkspace.test.tsx create mode 100644 src/workspaces/event-log/open-event-log-source.test.ts create mode 100644 src/workspaces/event-log/open-event-log-source.ts create mode 100644 src/workspaces/intune/IntuneDashboard.stories.test.tsx create mode 100644 src/workspaces/intune/NewIntuneWorkspace.stories.test.tsx create mode 100644 src/workspaces/intune/createIntuneOnOpenSource.test.ts create mode 100644 src/workspaces/intune/intune-story-fixtures.ts create mode 100644 src/workspaces/macos-diag/MacosDiagWorkspace.test.tsx create mode 100644 src/workspaces/secureboot/SecureBootWorkspace.test.tsx create mode 100644 src/workspaces/sysmon/SysmonWorkspace.test.tsx create mode 100644 src/workspaces/timeline/open-timeline-source.test.ts create mode 100644 src/workspaces/timeline/open-timeline-source.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b421496a8..11b0d7e3e 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,8 @@ 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. + - **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..f54106b15 --- /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). | 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 menu while Event Log is active uses the generic log loader, not evtx_parse_files.",SourcePicker Open .evtx files,src/workspaces/event-log/SourcePicker.tsx; evtx-store.ts,all,full,File/drag-drop while Event Log is active does not call evtx_parse_files. | Event Log empty picker: Open .evtx files... File Open now routes 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 while Timeline is active uses the generic log loader, not build_timeline_cmd.",File New Timeline submenu; drop,src/components/timeline/TimelineWorkspace.tsx; src-tauri/src/menu.rs,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/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("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..dcaca602d 100644 --- a/src/components/dialogs/CollectDiagnosticsDialog.tsx +++ b/src/components/dialogs/CollectDiagnosticsDialog.tsx @@ -200,6 +200,9 @@ export function CollectDiagnosticsDialog({ isOpen, onClose }: CollectDiagnostics }} >
({ + 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.tsx b/src/components/dialogs/FileAssociationPromptDialog.tsx index 463dc66a9..b1e9fedb3 100644 --- a/src/components/dialogs/FileAssociationPromptDialog.tsx +++ b/src/components/dialogs/FileAssociationPromptDialog.tsx @@ -95,6 +95,9 @@ export function FileAssociationPromptDialog({ }} >
{ + 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"); + }); +}); diff --git a/src/components/dialogs/FilterDialog.tsx b/src/components/dialogs/FilterDialog.tsx index f1cc54d87..8916f03c3 100644 --- a/src/components/dialogs/FilterDialog.tsx +++ b/src/components/dialogs/FilterDialog.tsx @@ -168,6 +168,9 @@ 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..def13ca15 --- /dev/null +++ b/src/components/dialogs/UpdateDialog.test.tsx @@ -0,0 +1,145 @@ +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, + }; + render(); + return { props }; +} + +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("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..f1f3bac49 100644 --- a/src/components/dialogs/UpdateDialog.tsx +++ b/src/components/dialogs/UpdateDialog.tsx @@ -241,7 +241,12 @@ 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..28359e3b8 --- /dev/null +++ b/src/components/dialogs/settings/FileAssociationsTab.test.tsx @@ -0,0 +1,55 @@ +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(); + }); + + it("offers Associate on Windows when not registered", () => { + useUiStore.setState({ currentPlatform: "windows" }); + render(); + expect( + screen.getByRole("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..6a588bc84 100644 --- a/src/components/dialogs/settings/GraphApiTab.test.tsx +++ b/src/components/dialogs/settings/GraphApiTab.test.tsx @@ -1434,4 +1434,12 @@ 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(); + }); }); 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/StatusBar.folder-progress.test.tsx b/src/components/layout/StatusBar.folder-progress.test.tsx new file mode 100644 index 000000000..cdffc6ece --- /dev/null +++ b/src/components/layout/StatusBar.folder-progress.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +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 { useLogStore } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { useFilterStore } from "../../stores/filter-store"; + +describe("StatusBar folder parse progress", () => { + beforeEach(() => { + 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", + }); + }); + + afterEach(() => { + cleanup(); + }); + + 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..d85363850 --- /dev/null +++ b/src/components/log-view/DnsWorkspaceBanner.test.tsx @@ -0,0 +1,83 @@ +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 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(); + }); +}); 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..e511b593c --- /dev/null +++ b/src/components/log-view/LogListView.selection.test.tsx @@ -0,0 +1,145 @@ +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 and ranges with Shift+click", () => { + render(); + fireEvent.click(screen.getByText("Policy evaluation 1 completed")); + fireEvent.click(screen.getByText("Policy evaluation 3 completed"), { metaKey: true }); + expect(screen.getByText("Policy evaluation 1 completed").closest("[role='option']")).toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + fireEvent.click(screen.getByText("Policy evaluation 5 completed"), { shiftKey: true }); + expect(screen.getByText("Policy evaluation 4 completed").closest("[role='option']")).toHaveStyle({ + outline: "1px solid rgba(59, 130, 246, 0.5)", + }); + }); + + it("selects every displayed row on Ctrl/Cmd+A", () => { + render(); + const list = screen.getByRole("listbox", { name: "Log entries" }); + fireEvent.keyDown(list, { key: "a", metaKey: true }); + 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..9831ef6d8 --- /dev/null +++ b/src/components/log-view/LogRow.stories.test.tsx @@ -0,0 +1,108 @@ +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 visibleColumns = [getColumnDef("severity")!, getColumnDef("message")!]; + +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.getByRole("option").firstElementChild as HTMLElement; + 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/MergeLegendBar.test.tsx b/src/components/log-view/MergeLegendBar.test.tsx new file mode 100644 index 000000000..f5445f13e --- /dev/null +++ b/src/components/log-view/MergeLegendBar.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MergeLegendBar } from "./MergeLegendBar"; +import { useLogStore } from "../../stores/log-store"; +import type { LogEntry } from "../../types/log"; + +function entry(id: number, filePath: string): LogEntry { + return { + id, + lineNumber: id, + message: `line ${id}`, + component: "CIAgent", + timestamp: id, + timestampDisplay: "2026-07-26 12:00:00.000", + severity: "Info", + thread: null, + threadDisplay: null, + sourceFile: null, + format: "Ccm", + filePath, + timezoneOffset: null, + }; +} + +describe("MergeLegendBar", () => { + 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)], + }, + }); + }); + + 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/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index 78ff072e3..ab121c0e1 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -405,25 +405,27 @@ export function useAppActions(): AppActionHandlers { const openPathForActiveWorkspace = useCallback( async (path: string) => { - if (activeWorkspace === "dsregcmd") { - useUiStore - .getState() - .ensureWorkspaceVisible("dsregcmd", "drag-drop.path-open"); - await analyzeDsregcmdPath(path, { fallbackToFolder: true }); - void recordRecentPath(path, "dsregcmd"); + const workspaceDefinition = getWorkspace(activeWorkspace); + if (workspaceDefinition.onOpenPath) { + await workspaceDefinition.onOpenPath(path); return; } - - if (isIntuneWorkspace(activeWorkspace)) { + 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", - ); + await workspaceDefinition.onOpenSource(source, "drag-drop.path-open"); + return; + } + + if (activeWorkspace === "dsregcmd") { + useUiStore + .getState() + .ensureWorkspaceVisible("dsregcmd", "drag-drop.path-open"); + await analyzeDsregcmdPath(path, { fallbackToFolder: true }); + void recordRecentPath(path, "dsregcmd"); return; } diff --git a/src/hooks/use-app-menu.test.tsx b/src/hooks/use-app-menu.test.tsx index 65f7d7669..c3cb10511 100644 --- a/src/hooks/use-app-menu.test.tsx +++ b/src/hooks/use-app-menu.test.tsx @@ -430,6 +430,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", () => { diff --git a/src/hooks/use-context-menu.test.ts b/src/hooks/use-context-menu.test.ts new file mode 100644 index 000000000..0dc835b2f --- /dev/null +++ b/src/hooks/use-context-menu.test.ts @@ -0,0 +1,99 @@ +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 }) => 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 }, +})); + +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + writeText: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { useContextMenu } from "./use-context-menu"; +import { renderHook } from "@testing-library/react"; +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(); + 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(); + }); +}); diff --git a/src/hooks/use-drag-drop.test.tsx b/src/hooks/use-drag-drop.test.tsx new file mode 100644 index 000000000..ec1dbb2ec --- /dev/null +++ b/src/hooks/use-drag-drop.test.tsx @@ -0,0 +1,150 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useTimelineStore } from "../stores/timeline-store"; +import { useUiStore } from "../stores/ui-store"; +import { useDragDrop } from "./use-drag-drop"; + +const { + openPathForActiveWorkspaceMock, + loadFilesAsLogSourceMock, + buildTimelineFromSourcesMock, + onDragDropEventMock, +} = vi.hoisted(() => ({ + openPathForActiveWorkspaceMock: vi.fn(), + loadFilesAsLogSourceMock: vi.fn(), + buildTimelineFromSourcesMock: 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, +})); + +vi.mock("../components/timeline/hooks/useTimelineBundle", () => ({ + buildTimelineFromSources: buildTimelineFromSourcesMock, +})); + +// Static import is safe: use-app-actions, log-source, and timeline bundle are mocked above. + +type DropHandler = (event: { + payload: { type: string; paths: string[] }; +}) => Promise | void; + +function latestHandler(): DropHandler { + const handler = onDragDropEventMock.mock.calls.at(-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(undefined); + buildTimelineFromSourcesMock.mockResolvedValue(undefined); + useUiStore.setState({ + activeWorkspace: "log", + activeView: "log", + }); + useTimelineStore.getState().reset(); + }); + + 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("unions dropped paths into the timeline workspace", async () => { + useUiStore.setState({ + activeWorkspace: "timeline", + activeView: "timeline", + }); + useTimelineStore.getState().setBundle({ + sources: [{ path: "/tmp/existing.log" }], + } as never); + renderHook(() => useDragDrop()); + + await latestHandler()({ + payload: { + type: "drop", + paths: ["/tmp/existing.log", "/tmp/new.log"], + }, + }); + + await waitFor(() => { + expect(buildTimelineFromSourcesMock).toHaveBeenCalledWith([ + { path: "/tmp/existing.log" }, + { path: "/tmp/new.log" }, + ]); + }); + expect(openPathForActiveWorkspaceMock).not.toHaveBeenCalled(); + expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); + }); + + 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-file-association.test.tsx b/src/hooks/use-file-association.test.tsx index 8761aa162..2a6718425 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; }, @@ -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/workspaces/deployment/DeploymentWorkspace.test.tsx b/src/workspaces/deployment/DeploymentWorkspace.test.tsx new file mode 100644 index 000000000..5226706e6 --- /dev/null +++ b/src/workspaces/deployment/DeploymentWorkspace.test.tsx @@ -0,0 +1,131 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DeploymentWorkspace } from "./DeploymentWorkspace"; +import { + useDeploymentStore, + type DeploymentAnalysisResult, + type DeploymentLogFile, +} from "./deployment-store"; + +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(); +}); + +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 errors" })).toBeInTheDocument(); + expect(screen.getByText("Installation failed with 1603")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "1 errors" })); + 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); + }); +}); diff --git a/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx new file mode 100644 index 000000000..d77ce28be --- /dev/null +++ b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx @@ -0,0 +1,417 @@ +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"; + +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 nullFacts(): 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: nullFacts(), + 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(() => { + 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/index.ts b/src/workspaces/event-log/index.ts index 7c32ffc8b..89002dbe4 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,10 @@ 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. + const { openEventLogSource } = await import("./open-event-log-source"); + await openEventLogSource(source); + }, }; 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..7a4fb4c9b --- /dev/null +++ b/src/workspaces/event-log/open-event-log-source.test.ts @@ -0,0 +1,78 @@ +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"); + +describe("openEventLogSource", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEvtxStore.setState({ + parseFiles: vi.fn(async () => undefined), + } as never); + }); + + it("parses a single evtx file", async () => { + await openEventLogSource({ kind: "file", path: "/tmp/Application.evtx" }); + expect(useEvtxStore.getState().parseFiles).toHaveBeenCalledWith([ + "/tmp/Application.evtx", + ]); + }); + + 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/); + }); +}); 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..e7bbc8627 --- /dev/null +++ b/src/workspaces/event-log/open-event-log-source.ts @@ -0,0 +1,42 @@ +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; + + 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); +} diff --git a/src/workspaces/intune/IntuneDashboard.stories.test.tsx b/src/workspaces/intune/IntuneDashboard.stories.test.tsx new file mode 100644 index 000000000..8b8fc4751 --- /dev/null +++ b/src/workspaces/intune/IntuneDashboard.stories.test.tsx @@ -0,0 +1,377 @@ +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: () => + 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, + ); + }); +} + +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..3eddb81cc --- /dev/null +++ b/src/workspaces/intune/createIntuneOnOpenSource.test.ts @@ -0,0 +1,90 @@ +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", () => { + it("forwards graphApiEnabled and does not include live event logs for a file source", async () => { + useUiStore.setState({ graphApiEnabled: true }); + const onOpen = createIntuneOnOpenSource("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 = createIntuneOnOpenSource("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 }, + ); + }); +}); diff --git a/src/workspaces/intune/intune-story-fixtures.ts b/src/workspaces/intune/intune-story-fixtures.ts new file mode 100644 index 000000000..cd074ef3d --- /dev/null +++ b/src/workspaces/intune/intune-story-fixtures.ts @@ -0,0 +1,292 @@ +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, + 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..de826316d --- /dev/null +++ b/src/workspaces/secureboot/SecureBootWorkspace.test.tsx @@ -0,0 +1,112 @@ +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 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(); +}); + +beforeEach(() => { + useSecureBootStore.getState().clear(); +}); + +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..f7f348582 --- /dev/null +++ b/src/workspaces/sysmon/SysmonWorkspace.test.tsx @@ -0,0 +1,143 @@ +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 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(); +}); + +beforeEach(() => { + useSysmonStore.getState().clear(); +}); + +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..ea0b185ef 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,13 @@ 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); + const { openTimelineSource } = await import("./open-timeline-source"); + await openTimelineSource(source); + }, }; 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..82d1cada7 --- /dev/null +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listLogFolder } from "../../lib/commands"; +import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; +import { useTimelineStore } from "../../stores/timeline-store"; +import { openTimelineSource } from "./open-timeline-source"; + +vi.mock("../../lib/commands", () => ({ + listLogFolder: vi.fn(), +})); + +vi.mock("../../components/timeline/hooks/useTimelineBundle", () => ({ + buildTimelineFromSources: vi.fn(async () => ({ sources: [] })), +})); + +describe("openTimelineSource", () => { + beforeEach(() => { + vi.clearAllMocks(); + useTimelineStore.setState({ bundle: null } as never); + }); + + 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("unions folder files with an existing timeline", async () => { + useTimelineStore.setState({ + bundle: { sources: [{ path: "/tmp/existing.log" }] }, + } as never); + 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" }, + ]); + }); +}); diff --git a/src/workspaces/timeline/open-timeline-source.ts b/src/workspaces/timeline/open-timeline-source.ts new file mode 100644 index 000000000..b3f43c1de --- /dev/null +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -0,0 +1,33 @@ +import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; +import { listLogFolder } from "../../lib/commands"; +import { useTimelineStore } from "../../stores/timeline-store"; +import type { LogSource } from "../../types/log"; + +export async function openTimelineSource(source: LogSource): Promise { + const existing = + useTimelineStore.getState().bundle?.sources.map((item) => item.path) ?? []; + + let incoming: string[] = []; + if (source.kind === "file") { + incoming = [source.path]; + } else if (source.kind === "folder") { + const listing = await listLogFolder(source.path); + incoming = listing.entries.filter((entry) => !entry.isDir).map((entry) => entry.path); + if (incoming.length === 0) { + incoming = [source.path]; + } + } else if (source.pathKind === "file") { + incoming = [source.defaultPath]; + } else { + const listing = await listLogFolder(source.defaultPath); + incoming = listing.entries.filter((entry) => !entry.isDir).map((entry) => entry.path); + if (incoming.length === 0) { + incoming = [source.defaultPath]; + } + } + + const merged = Array.from(new Set([...existing, ...incoming])).map((path) => ({ + path, + })); + await buildTimelineFromSources(merged); +} From 961957846005d085b96a14f176ac80598e4d5b20 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 13:54:31 -0400 Subject: [PATCH 02/30] fix(log): apply cached tab snapshot before folder restore Tab switches were waiting on listLogSourceFolder before swapping entries, so the chrome moved while the list stayed on the previous file. Apply the cached snapshot first; restore the sidebar after. --- CHANGELOG.md | 1 + src/lib/log-source.test.ts | 128 ++++++++++++++++++++++++++++++++++++- src/lib/log-source.ts | 20 ++++-- 3 files changed, 140 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b0d7e3e..42afe8a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ 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. - **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. diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index c51a96f4f..4653f0739 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FolderEntry, KnownSourceMetadata, + LogEntry, LogSource, ParseResult, } from "../types/log"; -import { useLogStore } from "../stores/log-store"; -import { loadLogSource } from "./log-source"; +import { useLogStore, setCachedTabSnapshot, clearAllTabSnapshots } from "../stores/log-store"; +import { useUiStore } from "../stores/ui-store"; +import { loadLogSource, switchToTab } from "./log-source"; const commands = vi.hoisted(() => ({ getKnownLogSources: vi.fn(), @@ -118,3 +120,125 @@ describe("Device Inventory known-source routing", () => { } ); }); + +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) { + return { + entries: [makeEntry(1, filePath, message)], + formatDetected: "Ccm" as const, + parserSelection: { + parser: "ccm" as const, + implementation: "ccm" as const, + provenance: "dedicated" as const, + parseQuality: "structured" as const, + recordFraming: "physicalLine" as const, + dateOrder: null, + }, + totalLines: 1, + byteOffset: 0, + selectedSourceFilePath: filePath, + sourceOpenMode: "single-file" as const, + activeColumns: ["severity", "dateTime", "message"] as const, + }; +} + +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, + }); + }); + + 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 }, + }); + + const listing = Promise.withResolvers<{ + sourceKind: "folder"; + source: LogSource; + entries: FolderEntry[]; + bundleMetadata: null; + }>(); + 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().entries.map((entry) => entry.message)).toEqual([ + "CIAgent line", + ]); + }); + + 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"); + }); +}); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index fe991ff20..8be5ef6ef 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -704,19 +704,14 @@ 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 + if (sourceContext?.sourceKind === "file") { logState.setActiveSource(sourceContext.source); 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.setOpenFilePath(filePath); logState.setSourceOpenMode(cached.sourceOpenMode); logState.setFormatDetected(cached.formatDetected); logState.setParserSelection(cached.parserSelection); @@ -730,6 +725,17 @@ export async function switchToTab( kind: "loaded", message: `Loaded ${getBaseName(filePath)}.`, }); + + if (sourceContext && sourceContext.sourceKind !== "file") { + try { + await restoreFolderContext(useLogStore.getState(), sourceContext); + } catch (error) { + console.warn("[log-source] folder context restore failed after tab switch", { + filePath, + error, + }); + } + } return; } From 5d7465043f30062e43a7bab4d517c09ffd5bed43 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 17:07:50 -0400 Subject: [PATCH 03/30] fix(qa): align tracker and File Open IME/dsregcmd paths EVTX-001 and TL-001 still described File > Open as the generic log loader after workspace onOpenSource routing landed. Timeline File > Open Folder now adds the IME folder source; empty folders are a no-op. dsregcmd drag-drop again analyzes with folder fallback and records Recent. --- CHANGELOG.md | 2 + docs/qa/user-stories.csv | 6 +-- .../settings/FileAssociationsTab.test.tsx | 10 +++- .../dialogs/settings/GraphApiTab.test.tsx | 3 ++ src/hooks/use-app-actions.path-open.test.tsx | 49 +++++++++++++++++++ src/hooks/use-app-actions.ts | 18 +++---- src/lib/dsregcmd-source.ts | 4 +- .../timeline/open-timeline-source.test.ts | 42 ++++++++++++++++ .../timeline/open-timeline-source.ts | 31 ++++++++---- 9 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 src/hooks/use-app-actions.path-open.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 42afe8a80..ed8b7e5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file. - **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. diff --git a/docs/qa/user-stories.csv b/docs/qa/user-stories.csv index f54106b15..2b0b8aaed 100644 --- a/docs/qa/user-stories.csv +++ b/docs/qa/user-stories.csv @@ -1,7 +1,7 @@ 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). | Known sources button disabled when catalog empty.",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,,, @@ -94,7 +94,7 @@ SCCM-001,sccm,Discover ConfigMgr roles,As a Windows admin I detect installed cli 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 menu while Event Log is active uses the generic log loader, not evtx_parse_files.",SourcePicker Open .evtx files,src/workspaces/event-log/SourcePicker.tsx; evtx-store.ts,all,full,File/drag-drop while Event Log is active does not call evtx_parse_files. | Event Log empty picker: Open .evtx files... File Open now routes 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-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,,, @@ -110,7 +110,7 @@ MACDIAG-001,macos-diag,Scan Mac environment and FDA gate,As a Mac admin I grant 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 while Timeline is active uses the generic log loader, not build_timeline_cmd.",File New Timeline submenu; drop,src/components/timeline/TimelineWorkspace.tsx; src-tauri/src/menu.rs,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-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, diff --git a/src/components/dialogs/settings/FileAssociationsTab.test.tsx b/src/components/dialogs/settings/FileAssociationsTab.test.tsx index 28359e3b8..772378b60 100644 --- a/src/components/dialogs/settings/FileAssociationsTab.test.tsx +++ b/src/components/dialogs/settings/FileAssociationsTab.test.tsx @@ -23,13 +23,19 @@ describe("FileAssociationsTab", () => { 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", () => { + it("offers Associate on Windows when not registered", async () => { useUiStore.setState({ currentPlatform: "windows" }); render(); expect( - screen.getByRole("button", { name: /Associate \.log files with CMTrace Open/ }), + await screen.findByRole("button", { name: /Associate \.log files with CMTrace Open/ }), ).toBeInTheDocument(); }); }); diff --git a/src/components/dialogs/settings/GraphApiTab.test.tsx b/src/components/dialogs/settings/GraphApiTab.test.tsx index 6a588bc84..a88b94e29 100644 --- a/src/components/dialogs/settings/GraphApiTab.test.tsx +++ b/src/components/dialogs/settings/GraphApiTab.test.tsx @@ -1441,5 +1441,8 @@ describe("GraphApiTab delegated capabilities", () => { 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/hooks/use-app-actions.path-open.test.tsx b/src/hooks/use-app-actions.path-open.test.tsx new file mode 100644 index 000000000..18d5dde74 --- /dev/null +++ b/src/hooks/use-app-actions.path-open.test.tsx @@ -0,0 +1,49 @@ +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()); + +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, +})); + +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"); + }); +}); diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index ab121c0e1..c92f4d4bc 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -405,6 +405,15 @@ export function useAppActions(): AppActionHandlers { const openPathForActiveWorkspace = useCallback( async (path: string) => { + if (activeWorkspace === "dsregcmd") { + useUiStore + .getState() + .ensureWorkspaceVisible("dsregcmd", "drag-drop.path-open"); + await analyzeDsregcmdPath(path, { fallbackToFolder: true }); + void recordRecentPath(path, "dsregcmd"); + return; + } + const workspaceDefinition = getWorkspace(activeWorkspace); if (workspaceDefinition.onOpenPath) { await workspaceDefinition.onOpenPath(path); @@ -420,15 +429,6 @@ export function useAppActions(): AppActionHandlers { return; } - if (activeWorkspace === "dsregcmd") { - useUiStore - .getState() - .ensureWorkspaceVisible("dsregcmd", "drag-drop.path-open"); - await analyzeDsregcmdPath(path, { fallbackToFolder: true }); - void recordRecentPath(path, "dsregcmd"); - return; - } - if (activeWorkspace === "deployment") { const { useDeploymentStore } = await import( "../workspaces/deployment/deployment-store" 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/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index 82d1cada7..22cc28fbc 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -44,4 +44,46 @@ describe("openTimelineSource", () => { { path: "/tmp/logs/a.log" }, ]); }); + + 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 () => { + vi.mocked(listLogFolder).mockResolvedValue({ + sourceKind: "folder", + source: { kind: "folder", path: "/tmp/empty" }, + entries: [], + }); + + await openTimelineSource({ kind: "folder", path: "/tmp/empty" }); + expect(buildTimelineFromSources).not.toHaveBeenCalled(); + }); + }); diff --git a/src/workspaces/timeline/open-timeline-source.ts b/src/workspaces/timeline/open-timeline-source.ts index b3f43c1de..8378603fc 100644 --- a/src/workspaces/timeline/open-timeline-source.ts +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -1,7 +1,22 @@ import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; import { listLogFolder } from "../../lib/commands"; import { useTimelineStore } from "../../stores/timeline-store"; -import type { LogSource } from "../../types/log"; +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; +} export async function openTimelineSource(source: LogSource): Promise { const existing = @@ -12,18 +27,16 @@ export async function openTimelineSource(source: LogSource): Promise { incoming = [source.path]; } else if (source.kind === "folder") { const listing = await listLogFolder(source.path); - incoming = listing.entries.filter((entry) => !entry.isDir).map((entry) => entry.path); - if (incoming.length === 0) { - incoming = [source.path]; - } + incoming = incomingFromListing(source.path, listing.entries); } else if (source.pathKind === "file") { incoming = [source.defaultPath]; } else { const listing = await listLogFolder(source.defaultPath); - incoming = listing.entries.filter((entry) => !entry.isDir).map((entry) => entry.path); - if (incoming.length === 0) { - incoming = [source.defaultPath]; - } + incoming = incomingFromListing(source.defaultPath, listing.entries); + } + + if (incoming.length === 0 && existing.length === 0) { + return; } const merged = Array.from(new Set([...existing, ...incoming])).map((path) => ({ From 58332b7f4a287ab4c473b223fbeda7d01d94bebe Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 17:13:52 -0400 Subject: [PATCH 04/30] fix(qa): close CodeRabbit typecheck and tab-switch gaps Restore selectedSourceFilePath on cached tab switch and drop stale folder listings. Unknown drag-drop paths try file then folder. Tests compile on ES2021 and narrow optional workspace handlers. --- .../log-view/LogRow.stories.test.tsx | 7 +- .../log-view/MergeLegendBar.test.tsx | 2 + src/hooks/use-app-actions.path-open.test.tsx | 35 ++++++ src/hooks/use-app-actions.ts | 30 ++++- src/hooks/use-drag-drop.test.tsx | 3 +- src/lib/log-source.test.ts | 112 ++++++++++++++++-- src/lib/log-source.ts | 28 +++-- .../intune/createIntuneOnOpenSource.test.ts | 12 +- 8 files changed, 197 insertions(+), 32 deletions(-) diff --git a/src/components/log-view/LogRow.stories.test.tsx b/src/components/log-view/LogRow.stories.test.tsx index 9831ef6d8..a3ea79656 100644 --- a/src/components/log-view/LogRow.stories.test.tsx +++ b/src/components/log-view/LogRow.stories.test.tsx @@ -6,7 +6,12 @@ import { themeSeverityPalettes } from "../../lib/themes/palettes"; import { DEFAULT_CATEGORIES } from "../../types/markers"; import type { LogEntry } from "../../types/log"; -const visibleColumns = [getColumnDef("severity")!, getColumnDef("message")!]; +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 { diff --git a/src/components/log-view/MergeLegendBar.test.tsx b/src/components/log-view/MergeLegendBar.test.tsx index f5445f13e..7a9c8ccf3 100644 --- a/src/components/log-view/MergeLegendBar.test.tsx +++ b/src/components/log-view/MergeLegendBar.test.tsx @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MergeLegendBar } from "./MergeLegendBar"; +import { buildMergeCacheKey } from "../../lib/merge-entries"; import { useLogStore } from "../../stores/log-store"; import type { LogEntry } from "../../types/log"; @@ -36,6 +37,7 @@ describe("MergeLegendBar", () => { 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 }), }, }); }); diff --git a/src/hooks/use-app-actions.path-open.test.tsx b/src/hooks/use-app-actions.path-open.test.tsx index 18d5dde74..28015f117 100644 --- a/src/hooks/use-app-actions.path-open.test.tsx +++ b/src/hooks/use-app-actions.path-open.test.tsx @@ -5,6 +5,7 @@ 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()); vi.mock("../lib/dsregcmd-source", () => ({ analyzeDsregcmdPath, @@ -21,6 +22,10 @@ vi.mock("../lib/commands", () => ({ inspectPathKind, })); +vi.mock("../workspaces/event-log/open-event-log-source", () => ({ + openEventLogSource, +})); + import { useAppActions } from "./use-app-actions"; describe("openPathForActiveWorkspace dsregcmd", () => { @@ -47,3 +52,33 @@ describe("openPathForActiveWorkspace dsregcmd", () => { 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", + }); + }); +}); diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index c92f4d4bc..f92d920d3 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -421,11 +421,31 @@ export function useAppActions(): AppActionHandlers { } if (workspaceDefinition.onOpenSource) { const pathKind = await inferPathKind(path); - const source: LogSource = - pathKind === "folder" - ? { kind: "folder", path } - : { kind: "file", path }; - await workspaceDefinition.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; } diff --git a/src/hooks/use-drag-drop.test.tsx b/src/hooks/use-drag-drop.test.tsx index ec1dbb2ec..6f5915f48 100644 --- a/src/hooks/use-drag-drop.test.tsx +++ b/src/hooks/use-drag-drop.test.tsx @@ -43,7 +43,8 @@ type DropHandler = (event: { }) => Promise | void; function latestHandler(): DropHandler { - const handler = onDragDropEventMock.mock.calls.at(-1)?.[0] as DropHandler | undefined; + const calls = onDragDropEventMock.mock.calls; + const handler = calls[calls.length - 1]?.[0] as DropHandler | undefined; if (!handler) { throw new Error("onDragDropEvent was not registered"); } diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 4653f0739..7472b6773 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -7,6 +7,7 @@ import type { ParseResult, } from "../types/log"; import { useLogStore, setCachedTabSnapshot, clearAllTabSnapshots } from "../stores/log-store"; +import type { TabEntrySnapshot } from "./tab-snapshot-cache"; import { useUiStore } from "../stores/ui-store"; import { loadLogSource, switchToTab } from "./log-source"; @@ -139,23 +140,23 @@ function makeEntry(id: number, filePath: string, message: string): LogEntry { }; } -function snapshotFor(filePath: string, message: string) { +function snapshotFor(filePath: string, message: string): TabEntrySnapshot { return { entries: [makeEntry(1, filePath, message)], - formatDetected: "Ccm" as const, + formatDetected: "Ccm", parserSelection: { - parser: "ccm" as const, - implementation: "ccm" as const, - provenance: "dedicated" as const, - parseQuality: "structured" as const, - recordFraming: "physicalLine" as const, + parser: "ccm", + implementation: "ccm", + provenance: "dedicated", + parseQuality: "structured", + recordFraming: "physicalLine", dateOrder: null, }, totalLines: 1, byteOffset: 0, selectedSourceFilePath: filePath, - sourceOpenMode: "single-file" as const, - activeColumns: ["severity", "dateTime", "message"] as const, + sourceOpenMode: "single-file", + activeColumns: ["severity", "dateTime", "message"], }; } @@ -187,13 +188,21 @@ describe("switchToTab", () => { activeSource: { kind: "file", path: fileA }, }); - const listing = Promise.withResolvers<{ + let resolveListing!: (value: { sourceKind: "folder"; source: LogSource; entries: FolderEntry[]; bundleMetadata: null; - }>(); - commands.listLogSourceFolder.mockReturnValue(listing.promise); + }) => void; + const listingPromise = new Promise<{ + sourceKind: "folder"; + source: LogSource; + entries: FolderEntry[]; + bundleMetadata: null; + }>((resolve) => { + resolveListing = resolve; + }); + commands.listLogSourceFolder.mockReturnValue(listingPromise); const pending = switchToTab(fileB, { sourceKind: "folder", @@ -203,12 +212,13 @@ describe("switchToTab", () => { 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", ]); }); - listing.resolve({ + resolveListing({ sourceKind: "folder", source: folderSource, entries: [], @@ -241,4 +251,80 @@ describe("switchToTab", () => { 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" }, + }); + let resolveFirst!: (value: { + sourceKind: "folder"; + source: LogSource; + entries: FolderEntry[]; + bundleMetadata: null; + }) => void; + const firstListing = new Promise<{ + sourceKind: "folder"; + source: LogSource; + entries: FolderEntry[]; + bundleMetadata: null; + }>((resolve) => { + resolveFirst = resolve; + }); + commands.listLogSourceFolder.mockReturnValueOnce(firstListing); + 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); + + resolveFirst({ + 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]); + }); + }); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index 8be5ef6ef..39b73259c 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -711,8 +711,8 @@ export async function switchToTab( } logState.setEntries(cached.entries); + logState.setSelectedSourceFilePath(cached.selectedSourceFilePath); logState.setOpenFilePath(filePath); - logState.setSourceOpenMode(cached.sourceOpenMode); logState.setFormatDetected(cached.formatDetected); logState.setParserSelection(cached.parserSelection); logState.setTotalLines(cached.totalLines); @@ -761,6 +761,8 @@ export async function switchToTab( await loadSelectedLogFile(filePath, source); } +let folderRestoreGeneration = 0; + /** Restore the sidebar folder listing if the active source changed. */ async function restoreFolderContext( logState: ReturnType, @@ -773,17 +775,23 @@ 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), - }); + if (!sourceChanged) { + return; + } - const listing = await listLogSourceFolder(source); - logState.setActiveSource(source); - logState.setSourceEntries(listing.entries); - logState.setBundleMetadata(listing.bundleMetadata ?? null); + const generation = ++folderRestoreGeneration; + console.info("[log-source] restoring folder context", { + sourceKind: source.kind, + sourcePath: getLogSourcePath(source), + }); + + const listing = await listLogSourceFolder(source); + if (generation !== folderRestoreGeneration) { + return; } + logState.setActiveSource(source); + logState.setSourceEntries(listing.entries); + logState.setBundleMetadata(listing.bundleMetadata ?? null); } /** diff --git a/src/workspaces/intune/createIntuneOnOpenSource.test.ts b/src/workspaces/intune/createIntuneOnOpenSource.test.ts index 3eddb81cc..b8d080578 100644 --- a/src/workspaces/intune/createIntuneOnOpenSource.test.ts +++ b/src/workspaces/intune/createIntuneOnOpenSource.test.ts @@ -54,9 +54,17 @@ beforeEach(() => { }); 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 = createIntuneOnOpenSource("intune"); + const onOpen = createOnOpen("intune"); await onOpen({ kind: "file", path: "C:/Logs/IME/AppWorkload.log" }, "test.open-file"); @@ -69,7 +77,7 @@ describe("INTUNE-009 analyzeIntuneLogs Graph option", () => { it("includes live event logs only for the known windows-intune-ime-logs source", async () => { useUiStore.setState({ graphApiEnabled: false }); - const onOpen = createIntuneOnOpenSource("new-intune"); + const onOpen = createOnOpen("new-intune"); await onOpen( { From e06fc814f4e92aee1d48b9c2a42eb914b70e4be0 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 17:15:20 -0400 Subject: [PATCH 05/30] fix(timeline): keep empty folder opens as a no-op Opening an empty folder with an existing timeline no longer rebuilds the current sources. File > Open Folder stays a no-op when the listing has no files. --- src/workspaces/timeline/open-timeline-source.test.ts | 3 +++ src/workspaces/timeline/open-timeline-source.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index 22cc28fbc..254128905 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -76,6 +76,9 @@ describe("openTimelineSource", () => { }); it("does not treat an empty folder as an IntuneEvents source", async () => { + useTimelineStore.setState({ + bundle: { sources: [{ path: "/tmp/existing.log" }] }, + } as never); vi.mocked(listLogFolder).mockResolvedValue({ sourceKind: "folder", source: { kind: "folder", path: "/tmp/empty" }, diff --git a/src/workspaces/timeline/open-timeline-source.ts b/src/workspaces/timeline/open-timeline-source.ts index 8378603fc..5c384bb44 100644 --- a/src/workspaces/timeline/open-timeline-source.ts +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -35,7 +35,7 @@ export async function openTimelineSource(source: LogSource): Promise { incoming = incomingFromListing(source.defaultPath, listing.entries); } - if (incoming.length === 0 && existing.length === 0) { + if (incoming.length === 0) { return; } From 67e3d3f709b49f084054f15f66ec38cf3d847f93 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 19:19:06 -0400 Subject: [PATCH 06/30] fix(qa): close CodeRabbit review gaps for tracker --- .../dialogs/CollectDiagnosticsDialog.test.tsx | 39 ++++++ .../dialogs/CollectionCompleteDialog.tsx | 7 +- .../FileAssociationPromptDialog.test.tsx | 48 +++++++ .../dialogs/FileAssociationPromptDialog.tsx | 7 +- src/components/dialogs/FilterDialog.test.tsx | 45 ++++++- src/components/dialogs/FilterDialog.tsx | 6 +- src/components/dialogs/UpdateDialog.test.tsx | 43 ++++++- src/components/dialogs/UpdateDialog.tsx | 8 +- .../log-view/DnsWorkspaceBanner.test.tsx | 6 +- .../log-view/DnsWorkspaceBanner.tsx | 11 +- .../log-view/LogListView.selection.test.tsx | 6 + src/hooks/use-keyboard.ts | 9 ++ src/hooks/use-modal-focus.ts | 82 ++++++++++++ src/lib/log-source.test.ts | 117 +++++++++++++----- src/lib/log-source.ts | 2 + src/stores/ui-store.ts | 4 + .../sysmon/SysmonWorkspace.test.tsx | 4 + 17 files changed, 401 insertions(+), 43 deletions(-) create mode 100644 src/components/dialogs/FileAssociationPromptDialog.test.tsx create mode 100644 src/hooks/use-modal-focus.ts diff --git a/src/components/dialogs/CollectDiagnosticsDialog.test.tsx b/src/components/dialogs/CollectDiagnosticsDialog.test.tsx index cabc9dc97..7667f9c85 100644 --- a/src/components/dialogs/CollectDiagnosticsDialog.test.tsx +++ b/src/components/dialogs/CollectDiagnosticsDialog.test.tsx @@ -75,6 +75,45 @@ describe("CollectionCompleteDialog", () => { 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( diff --git a/src/components/dialogs/CollectionCompleteDialog.tsx b/src/components/dialogs/CollectionCompleteDialog.tsx index 3b244315f..072ba9a87 100644 --- a/src/components/dialogs/CollectionCompleteDialog.tsx +++ b/src/components/dialogs/CollectionCompleteDialog.tsx @@ -1,9 +1,10 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { tokens } from "@fluentui/react-components"; import { CheckmarkRegular, DismissRegular } from "@fluentui/react-icons"; import type { CollectionResult } from "../../lib/commands"; import { loadPathAsLogSource } from "../../lib/log-source"; import { useUiStore } from "../../stores/ui-store"; +import { useModalFocus } from "../../hooks/use-modal-focus"; import { getThemeById } from "../../lib/themes"; interface CollectionCompleteDialogProps { @@ -13,6 +14,8 @@ interface CollectionCompleteDialogProps { export function CollectionCompleteDialog({ result, onClose }: CollectionCompleteDialogProps) { const [showGaps, setShowGaps] = useState(false); + const dialogRef = useRef(null); + useModalFocus(result !== null, dialogRef); const themeId = useUiStore((s) => s.themeId); const statusPalette = useMemo( () => getThemeById(themeId).severityPalette.status, @@ -53,9 +56,11 @@ export function CollectionCompleteDialog({ result, onClose }: CollectionComplete }} >
({ + associateLogFilesWithApp: vi.fn(), + setFileAssociationPromptSuppressed: vi.fn(), +})); + +describe("FileAssociationPromptDialog", () => { + it("traps focus and restores the opener when closed", () => { + vi.mocked(associateLogFilesWithApp).mockResolvedValue(undefined); + vi.mocked(setFileAssociationPromptSuppressed).mockResolvedValue(undefined); + + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + 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); + + rendered.rerender( + {}} />, + ); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); +}); diff --git a/src/components/dialogs/FileAssociationPromptDialog.tsx b/src/components/dialogs/FileAssociationPromptDialog.tsx index b1e9fedb3..bdf490087 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,8 @@ export function FileAssociationPromptDialog({ }: FileAssociationPromptDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const dialogRef = useRef(null); + useModalFocus(isOpen, dialogRef); useEffect(() => { if (!isOpen) { @@ -95,9 +98,11 @@ export function FileAssociationPromptDialog({ }} >
{ 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 firstFocusable = dialog.querySelector("select"); + const close = screen.getByRole("button", { name: "Cancel" }); + + expect(input).not.toBeNull(); + expect(document.activeElement).toBe(input); + + close.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(firstFocusable); + fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(close); + + opener.focus(); + fireEvent.keyDown(window, { key: "Tab" }); + expect(document.activeElement).toBe(firstFocusable); + + rendered.rerender( + {}} + onApply={async () => undefined} + currentClauses={[]} + />, + ); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); }); diff --git a/src/components/dialogs/FilterDialog.tsx b/src/components/dialogs/FilterDialog.tsx index 8916f03c3..458eef925 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,11 @@ 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); useEffect(() => { if (isOpen) { @@ -66,7 +69,6 @@ export function FilterDialog({ ? [...currentClauses] : [emptyClause()] ); - setTimeout(() => inputRef.current?.focus(), 50); } }, [isOpen, currentClauses]); @@ -168,8 +170,10 @@ export function FilterDialog({ }} >
); - return { props }; + const rendered = render(); + return { props, ...rendered }; } const availableUpdate = (overrides: Partial = {}): UpdateInfo => ({ @@ -51,6 +51,45 @@ describe("UpdateDialog", () => { 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("shows Cancel while checking", () => { const { props } = renderDialog({ isChecking: true }); diff --git a/src/components/dialogs/UpdateDialog.tsx b/src/components/dialogs/UpdateDialog.tsx index f1f3bac49..adec9706d 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,9 @@ export function UpdateDialog({ onOpenReleasePage, onSkipVersion, }: UpdateDialogProps) { + const dialogRef = useRef(null); + + useModalFocus(isOpen, dialogRef); useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { @@ -242,9 +246,11 @@ export function UpdateDialog({ }} >
{renderContent()} diff --git a/src/components/log-view/DnsWorkspaceBanner.test.tsx b/src/components/log-view/DnsWorkspaceBanner.test.tsx index d85363850..179157b48 100644 --- a/src/components/log-view/DnsWorkspaceBanner.test.tsx +++ b/src/components/log-view/DnsWorkspaceBanner.test.tsx @@ -79,5 +79,9 @@ describe("DnsWorkspaceBanner", () => { render(); fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); expect(screen.queryByText(/This looks like a DNS debug log/)).toBeNull(); - }); + + cleanup(); + 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..72eb647d0 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,14 @@ 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 dismissedDnsBannerPath = useUiStore((s) => s.dismissedDnsBannerPath); + const setDismissedDnsBannerPath = useUiStore((s) => s.setDismissedDnsBannerPath); const parser = parserSelection?.parser; const label = parser ? PARSER_LABELS[parser] : undefined; + const dismissed = openFilePath !== null && dismissedDnsBannerPath === openFilePath; const handleOpenInWorkspace = useCallback(() => { const logState = useLogStore.getState(); @@ -61,7 +64,9 @@ export function DnsWorkspaceBanner() { size="small" appearance="subtle" icon={} - onClick={() => setDismissed(true)} + onClick={() => { + if (openFilePath) setDismissedDnsBannerPath(openFilePath); + }} aria-label="Dismiss" />
diff --git a/src/components/log-view/LogListView.selection.test.tsx b/src/components/log-view/LogListView.selection.test.tsx index e511b593c..ead1f41be 100644 --- a/src/components/log-view/LogListView.selection.test.tsx +++ b/src/components/log-view/LogListView.selection.test.tsx @@ -101,6 +101,11 @@ describe("LogListView selection and jump fixtures", () => { 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)", }); @@ -114,6 +119,7 @@ describe("LogListView selection and jump fixtures", () => { render(); const list = screen.getByRole("listbox", { name: "Log entries" }); fireEvent.keyDown(list, { key: "a", metaKey: true }); + fireEvent.keyDown(list, { key: "a", ctrlKey: true }); 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)", diff --git a/src/hooks/use-keyboard.ts b/src/hooks/use-keyboard.ts index 2650f72ab..d010b588a 100644 --- a/src/hooks/use-keyboard.ts +++ b/src/hooks/use-keyboard.ts @@ -125,6 +125,9 @@ export function useKeyboard() { const showFileAssociationPromptOpen = useUiStore( (state) => state.showFileAssociationPrompt ); + const showUpdateDialogOpen = useUiStore( + (state) => state.showUpdateDialog, + ); const { commandState, openSourceFileDialog, @@ -159,6 +162,10 @@ export function useKeyboard() { // break both. return; } + if (showUpdateDialogOpen) { + if (event.ctrlKey || event.metaKey) event.preventDefault(); + return; + } const ctrl = event.ctrlKey || event.metaKey; const isInput = isTypingTarget(event.target); @@ -169,6 +176,7 @@ export function useKeyboard() { showAboutDialogOpen || showSettingsDialogOpen || showEvidenceBundleDialogOpen || + showUpdateDialogOpen || showFileAssociationPromptOpen; if (ctrl && !isInput && commandState.canAdjustTextSize) { @@ -355,6 +363,7 @@ export function useKeyboard() { showFilterDialog, showFilterDialogOpen, showFileAssociationPromptOpen, + 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..465bc2ac7 --- /dev/null +++ b/src/hooks/use-modal-focus.ts @@ -0,0 +1,82 @@ +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(", "); + +export function useModalFocus( + isOpen: boolean, + surfaceRef: RefObject, + initialFocusRef?: RefObject, +): 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) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + + const surface = surfaceRef.current; + if (!surface) return; + + const focusable = Array.from( + surface.querySelectorAll(FOCUSABLE_SELECTOR), + ); + 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 || !surface.contains(active)) { + event.preventDefault(); + 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/lib/log-source.test.ts b/src/lib/log-source.test.ts index 7472b6773..4f4699688 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -160,6 +160,30 @@ function snapshotFor(filePath: string, message: string): TabEntrySnapshot { }; } +type FolderListing = { + sourceKind: "folder"; + source: LogSource; + entries: FolderEntry[]; + bundleMetadata: null; +}; + +function deferred() { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { + promise, + resolve(value: T) { + if (!resolvePromise) { + throw new Error("Deferred promise resolver was not initialized"); + } + resolvePromise(value); + }, + }; +} + describe("switchToTab", () => { const fileA = "C:/Windows/CCM/Logs/AppEnforce.log"; const fileB = "C:/Windows/CCM/Logs/CIAgent.log"; @@ -186,23 +210,11 @@ describe("switchToTab", () => { selectedSourceFilePath: fileA, entries: snapshotFor(fileA, "AppEnforce line").entries, activeSource: { kind: "file", path: fileA }, + sourceOpenMode: "aggregate-folder", }); - let resolveListing!: (value: { - sourceKind: "folder"; - source: LogSource; - entries: FolderEntry[]; - bundleMetadata: null; - }) => void; - const listingPromise = new Promise<{ - sourceKind: "folder"; - source: LogSource; - entries: FolderEntry[]; - bundleMetadata: null; - }>((resolve) => { - resolveListing = resolve; - }); - commands.listLogSourceFolder.mockReturnValue(listingPromise); + const listing = deferred(); + commands.listLogSourceFolder.mockReturnValue(listing.promise); const pending = switchToTab(fileB, { sourceKind: "folder", @@ -217,8 +229,9 @@ describe("switchToTab", () => { "CIAgent line", ]); }); + expect(useLogStore.getState().sourceOpenMode).toBe("single-file"); - resolveListing({ + listing.resolve({ sourceKind: "folder", source: folderSource, entries: [], @@ -264,21 +277,8 @@ describe("switchToTab", () => { entries: snapshotFor(fileC, "Start line").entries, activeSource: { kind: "folder", path: "C:/Windows/CCM/Logs/Start" }, }); - let resolveFirst!: (value: { - sourceKind: "folder"; - source: LogSource; - entries: FolderEntry[]; - bundleMetadata: null; - }) => void; - const firstListing = new Promise<{ - sourceKind: "folder"; - source: LogSource; - entries: FolderEntry[]; - bundleMetadata: null; - }>((resolve) => { - resolveFirst = resolve; - }); - commands.listLogSourceFolder.mockReturnValueOnce(firstListing); + const firstListing = deferred(); + commands.listLogSourceFolder.mockReturnValueOnce(firstListing.promise); commands.listLogSourceFolder.mockResolvedValueOnce({ sourceKind: "folder", source: otherFolder, @@ -308,7 +308,7 @@ describe("switchToTab", () => { await second; expect(useLogStore.getState().activeSource).toEqual(otherFolder); - resolveFirst({ + firstListing.resolve({ sourceKind: "folder", source: folderSource, entries: [ @@ -327,4 +327,57 @@ describe("switchToTab", () => { 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(); + }); }); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index 39b73259c..574a507bd 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -665,6 +665,7 @@ export async function switchToTab( // Already showing this file — nothing to do if (currentPath === filePath) return; + folderRestoreGeneration += 1; // ── Registry tab: restore from registry cache ────────────────────── { @@ -720,6 +721,7 @@ export async function switchToTab( logState.setActiveColumns(cached.activeColumns); useUiStore.getState().resetColumnWidths(); logState.setAggregateFiles([]); + logState.setSourceOpenMode(cached.sourceOpenMode); logState.selectEntry(null); logState.setSourceStatus({ kind: "loaded", diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index b02f080db..edddf30c6 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; + dismissedDnsBannerPath: string | null; 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; + setDismissedDnsBannerPath: (path: string | null) => void; addRecentSession: (path: string) => void; clearRecentSessions: () => void; setGraphApiEnabled: (enabled: boolean) => void; @@ -344,6 +346,7 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, + dismissedDnsBannerPath: null, recentSessions: [], graphApiEnabled: false, graphApiStatus: "disconnected", @@ -639,6 +642,7 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), + setDismissedDnsBannerPath: (path) => set({ dismissedDnsBannerPath: path }), addRecentSession: (path) => set((state) => { const filtered = state.recentSessions.filter((p) => p !== path); diff --git a/src/workspaces/sysmon/SysmonWorkspace.test.tsx b/src/workspaces/sysmon/SysmonWorkspace.test.tsx index f7f348582..cc18e79cc 100644 --- a/src/workspaces/sysmon/SysmonWorkspace.test.tsx +++ b/src/workspaces/sysmon/SysmonWorkspace.test.tsx @@ -2,6 +2,7 @@ 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", () => ({ @@ -92,10 +93,13 @@ function analysis(): SysmonAnalysisResult { 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", () => { From edf71c009af605710cfd8cde07ac26fe9a27343b Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 19:22:37 -0400 Subject: [PATCH 07/30] test: isolate SecureBoot platform fixture --- src/workspaces/secureboot/SecureBootWorkspace.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/workspaces/secureboot/SecureBootWorkspace.test.tsx b/src/workspaces/secureboot/SecureBootWorkspace.test.tsx index de826316d..04f53fb03 100644 --- a/src/workspaces/secureboot/SecureBootWorkspace.test.tsx +++ b/src/workspaces/secureboot/SecureBootWorkspace.test.tsx @@ -2,6 +2,7 @@ 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 { @@ -75,10 +76,13 @@ function analysis(): SecureBootAnalysisResult { 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", () => { From 14da6cf3488b9d006a6a47a868c2c42b8b885e95 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 21:21:45 -0400 Subject: [PATCH 08/30] fix: close PR 577 review regressions --- .../dialogs/CollectDiagnosticsDialog.tsx | 5 + .../FileAssociationPromptDialog.test.tsx | 3 + src/components/layout/AppShell.tsx | 3 - .../log-view/DnsWorkspaceBanner.test.tsx | 11 +- .../log-view/DnsWorkspaceBanner.tsx | 9 +- src/hooks/use-app-menu.test.tsx | 54 ++++ src/hooks/use-drag-drop.test.tsx | 36 +-- src/hooks/use-drag-drop.ts | 14 +- src/hooks/use-keyboard.ts | 90 ++++-- src/hooks/use-modal-focus.ts | 2 +- src/lib/log-source.test.ts | 286 +++++++++++++++++- src/lib/log-source.ts | 161 ++++++---- src/stores/ui-store.ts | 13 +- 13 files changed, 557 insertions(+), 130 deletions(-) diff --git a/src/components/dialogs/CollectDiagnosticsDialog.tsx b/src/components/dialogs/CollectDiagnosticsDialog.tsx index dcaca602d..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,9 +203,11 @@ export function CollectDiagnosticsDialog({ isOpen, onClose }: CollectDiagnostics }} >
{ 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( {}} />, ); 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/log-view/DnsWorkspaceBanner.test.tsx b/src/components/log-view/DnsWorkspaceBanner.test.tsx index 179157b48..583a15846 100644 --- a/src/components/log-view/DnsWorkspaceBanner.test.tsx +++ b/src/components/log-view/DnsWorkspaceBanner.test.tsx @@ -58,7 +58,7 @@ describe("DnsWorkspaceBanner", () => { cleanup(); }); - it("offers a DNS/DHCP handoff and dismisses for the session", () => { + it("offers a DNS/DHCP handoff and dismisses each path for the session", () => { render(); expect( screen.getByText(/This looks like a DNS debug log/), @@ -81,7 +81,14 @@ describe("DnsWorkspaceBanner", () => { 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 72eb647d0..84bac2bd2 100644 --- a/src/components/log-view/DnsWorkspaceBanner.tsx +++ b/src/components/log-view/DnsWorkspaceBanner.tsx @@ -18,12 +18,13 @@ export function DnsWorkspaceBanner() { const parserSelection = useLogStore((s) => s.parserSelection); const openFilePath = useLogStore((s) => s.openFilePath); const activeWorkspace = useUiStore((s) => s.activeWorkspace); - const dismissedDnsBannerPath = useUiStore((s) => s.dismissedDnsBannerPath); - const setDismissedDnsBannerPath = useUiStore((s) => s.setDismissedDnsBannerPath); + 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 && dismissedDnsBannerPath === openFilePath; + const dismissed = + openFilePath !== null && dismissedDnsBannerPaths.includes(openFilePath); const handleOpenInWorkspace = useCallback(() => { const logState = useLogStore.getState(); @@ -65,7 +66,7 @@ export function DnsWorkspaceBanner() { appearance="subtle" icon={} onClick={() => { - if (openFilePath) setDismissedDnsBannerPath(openFilePath); + if (openFilePath) dismissDnsBannerPath(openFilePath); }} aria-label="Dismiss" /> diff --git a/src/hooks/use-app-menu.test.tsx b/src/hooks/use-app-menu.test.tsx index c3cb10511..0bac5a3b8 100644 --- a/src/hooks/use-app-menu.test.tsx +++ b/src/hooks/use-app-menu.test.tsx @@ -465,7 +465,12 @@ describe("useKeyboard native menu parity", () => { showAboutDialog: false, showSettingsDialog: false, showEvidenceBundleDialog: false, + showGuidRegistryDialog: false, + showMergeTabsDialog: false, + showDiffConfigDialog: false, showFileAssociationPrompt: false, + showCollectDiagnosticsDialog: false, + collectionResult: null, }); }); @@ -518,6 +523,55 @@ 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("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-drag-drop.test.tsx b/src/hooks/use-drag-drop.test.tsx index 6f5915f48..2b6560f8d 100644 --- a/src/hooks/use-drag-drop.test.tsx +++ b/src/hooks/use-drag-drop.test.tsx @@ -1,18 +1,15 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useTimelineStore } from "../stores/timeline-store"; import { useUiStore } from "../stores/ui-store"; import { useDragDrop } from "./use-drag-drop"; const { openPathForActiveWorkspaceMock, loadFilesAsLogSourceMock, - buildTimelineFromSourcesMock, onDragDropEventMock, } = vi.hoisted(() => ({ openPathForActiveWorkspaceMock: vi.fn(), loadFilesAsLogSourceMock: vi.fn(), - buildTimelineFromSourcesMock: vi.fn(), onDragDropEventMock: vi.fn(), })); @@ -32,11 +29,7 @@ vi.mock("../lib/log-source", () => ({ loadFilesAsLogSource: loadFilesAsLogSourceMock, })); -vi.mock("../components/timeline/hooks/useTimelineBundle", () => ({ - buildTimelineFromSources: buildTimelineFromSourcesMock, -})); - -// Static import is safe: use-app-actions, log-source, and timeline bundle are mocked above. +// Static imports are safe: app actions and log-source are mocked above. type DropHandler = (event: { payload: { type: string; paths: string[] }; @@ -57,12 +50,10 @@ describe("useDragDrop", () => { onDragDropEventMock.mockResolvedValue(() => undefined); openPathForActiveWorkspaceMock.mockResolvedValue(undefined); loadFilesAsLogSourceMock.mockResolvedValue(undefined); - buildTimelineFromSourcesMock.mockResolvedValue(undefined); useUiStore.setState({ activeWorkspace: "log", activeView: "log", }); - useTimelineStore.getState().reset(); }); it("opens a single dropped path on the active workspace", async () => { @@ -111,30 +102,29 @@ describe("useDragDrop", () => { expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); }); - it("unions dropped paths into the timeline workspace", async () => { + it("routes every timeline drop through the active workspace opener", async () => { useUiStore.setState({ activeWorkspace: "timeline", activeView: "timeline", }); - useTimelineStore.getState().setBundle({ - sources: [{ path: "/tmp/existing.log" }], - } as never); renderHook(() => useDragDrop()); await latestHandler()({ payload: { type: "drop", - paths: ["/tmp/existing.log", "/tmp/new.log"], + paths: ["/tmp/ime.log", "/tmp/empty-folder"], }, }); - await waitFor(() => { - expect(buildTimelineFromSourcesMock).toHaveBeenCalledWith([ - { path: "/tmp/existing.log" }, - { path: "/tmp/new.log" }, - ]); - }); - expect(openPathForActiveWorkspaceMock).not.toHaveBeenCalled(); + expect(openPathForActiveWorkspaceMock).toHaveBeenNthCalledWith( + 1, + "/tmp/ime.log", + ); + expect(openPathForActiveWorkspaceMock).toHaveBeenNthCalledWith( + 2, + "/tmp/empty-folder", + ); + expect(openPathForActiveWorkspaceMock).toHaveBeenCalledTimes(2); expect(loadFilesAsLogSourceMock).not.toHaveBeenCalled(); }); diff --git a/src/hooks/use-drag-drop.ts b/src/hooks/use-drag-drop.ts index 64fc872cd..0f4d0e1a1 100644 --- a/src/hooks/use-drag-drop.ts +++ b/src/hooks/use-drag-drop.ts @@ -29,17 +29,9 @@ 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) { + await openPathForActiveWorkspace(path); + } return; } diff --git a/src/hooks/use-keyboard.ts b/src/hooks/use-keyboard.ts index d010b588a..102db1eb8 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,37 @@ 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, @@ -148,36 +175,36 @@ 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; - } - if (showUpdateDialogOpen) { - if (event.ctrlKey || event.metaKey) event.preventDefault(); - return; - } - - const ctrl = event.ctrlKey || event.metaKey; + const suppressibleShortcut = + event.ctrlKey || event.metaKey || /^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 || + showGuidRegistryDialogOpen || + showMergeTabsDialogOpen || + showDiffConfigDialogOpen || + showFileAssociationPromptOpen || + showCollectDiagnosticsDialogOpen || + collectionResultOpen || showUpdateDialogOpen || - showFileAssociationPromptOpen; + 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(); @@ -348,6 +375,7 @@ export function useKeyboard() { commandState.canAdjustTextSize, decreaseLogListTextSize, dismissTransientDialogs, + collectionResultOpen, elevationPromptOpen, findNext, findPrevious, @@ -363,6 +391,10 @@ export function useKeyboard() { showFilterDialog, showFilterDialogOpen, showFileAssociationPromptOpen, + showCollectDiagnosticsDialogOpen, + showDiffConfigDialogOpen, + showGuidRegistryDialogOpen, + showMergeTabsDialogOpen, showUpdateDialogOpen, showFindBar, showFindBarOpen, diff --git a/src/hooks/use-modal-focus.ts b/src/hooks/use-modal-focus.ts index 465bc2ac7..9095aca68 100644 --- a/src/hooks/use-modal-focus.ts +++ b/src/hooks/use-modal-focus.ts @@ -62,7 +62,7 @@ export function useModalFocus( if (!active || !surface.contains(active)) { event.preventDefault(); - first.focus(); + (event.shiftKey ? last : first).focus(); return; } if (event.shiftKey && active === first) { diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 4f4699688..11def7492 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -6,10 +6,11 @@ import type { LogSource, ParseResult, } from "../types/log"; +import type { EvidenceBundleMetadata } from "../types/evidence"; import { useLogStore, setCachedTabSnapshot, clearAllTabSnapshots } from "../stores/log-store"; import type { TabEntrySnapshot } from "./tab-snapshot-cache"; import { useUiStore } from "../stores/ui-store"; -import { loadLogSource, switchToTab } from "./log-source"; +import { loadLogSource, loadSelectedLogFile, switchToTab } from "./log-source"; const commands = vi.hoisted(() => ({ getKnownLogSources: vi.fn(), @@ -159,6 +160,34 @@ function snapshotFor(filePath: string, message: string): TabEntrySnapshot { 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, + }, + }; +} type FolderListing = { sourceKind: "folder"; @@ -200,8 +229,50 @@ describe("switchToTab", () => { 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("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")); @@ -380,4 +451,217 @@ describe("switchToTab", () => { 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("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("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, + }); + }); }); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index 574a507bd..a57c89012 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 === undefined || generation === tabSwitchGeneration; +} export interface KnownSourceCatalogActionIds { sourceId?: string | null; @@ -159,12 +165,23 @@ async function stopCurrentTailIfNeeded(nextFilePath: string | null): Promise { + if (!isCurrentTabSwitch(switchGeneration)) return; 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) { + console.error("[log-source] failed to load registry file", err); + throw err; + } + const { setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); + if (!isCurrentTabSwitch(switchGeneration)) return; + state.setActiveSource(source); state.setSelectedSourceFilePath(selectedFilePath); state.setSourceOpenMode("single-file"); @@ -177,7 +194,6 @@ async function applyParseResultToStore( message: `Loaded ${getBaseName(selectedFilePath)}.`, }); - // Cache a minimal snapshot so tab switching works setCachedTabSnapshot(selectedFilePath, { entries: [], formatDetected: result.formatDetected, @@ -191,17 +207,8 @@ 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); - } + setCachedRegistry(selectedFilePath, registryData); + useRegistryStore.getState().setRegistryData(registryData); return; } @@ -550,12 +557,22 @@ export async function getKnownSourceMetadataById( return knownSources.find((source) => source.id === sourceId) ?? null; } +export function loadSelectedLogFile( + filePath: string, + source: LogSource, +): Promise; +export function loadSelectedLogFile( + filePath: string, + source: LogSource, + switchGeneration: number, +): Promise; export async function loadSelectedLogFile( filePath: string, - source: LogSource -): Promise { + source: LogSource, + switchGeneration?: number, +): Promise { + if (!isCurrentTabSwitch(switchGeneration)) return null; const state = useLogStore.getState(); - // 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 +580,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(switchGeneration)) return null; + + let regData = getCachedRegistry(filePath); + if (!regData) { + regData = await parseRegistryFile(filePath); + if (!isCurrentTabSwitch(switchGeneration)) return null; + setCachedRegistry(filePath, regData); + } + if (!isCurrentTabSwitch(switchGeneration)) return null; + state.setSelectedSourceFilePath(filePath); state.setSourceOpenMode("single-file"); state.setEntries([]); @@ -574,14 +603,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 +656,30 @@ export async function loadSelectedLogFile( filePath, }); + if (!isCurrentTabSwitch(switchGeneration)) return null; state.setLoading(true); state.setSourceStatus({ kind: "loading", message: `Loading ${getBaseName(filePath)}...`, }); - await stopCurrentTailIfNeeded(filePath); try { + await stopCurrentTailIfNeeded(filePath); + if (!isCurrentTabSwitch(switchGeneration)) return null; + const result = await openLogFile(filePath); - await applyParseResultToStore(source, result.filePath, result); + if (!isCurrentTabSwitch(switchGeneration)) return result; + await applyParseResultToStore( + source, + result.filePath, + result, + switchGeneration, + ); return result; } finally { - state.setLoading(false); + if (isCurrentTabSwitch(switchGeneration)) { + state.setLoading(false); + } } } @@ -662,10 +694,12 @@ export async function switchToTab( ): Promise { const logState = useLogStore.getState(); const currentPath = logState.openFilePath; + const generation = ++tabSwitchGeneration; + logState.setLoading(false); - // 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; - folderRestoreGeneration += 1; // ── Registry tab: restore from registry cache ────────────────────── { @@ -679,7 +713,9 @@ export async function switchToTab( // Restore sidebar context if (sourceContext && sourceContext.sourceKind !== "file") { - await restoreFolderContext(logState, sourceContext); + if (!(await restoreFolderContext(logState, sourceContext, generation))) { + return; + } } else if (sourceContext?.sourceKind === "file") { logState.setActiveSource(sourceContext.source); logState.setSourceEntries([]); @@ -688,11 +724,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); } @@ -705,8 +743,14 @@ export async function switchToTab( if (cached) { console.info("[log-source] tab switch from cache (instant)", { filePath }); - if (sourceContext?.sourceKind === "file") { - 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); } @@ -730,7 +774,12 @@ export async function switchToTab( if (sourceContext && sourceContext.sourceKind !== "file") { try { - await restoreFolderContext(useLogStore.getState(), sourceContext); + const restored = await restoreFolderContext( + useLogStore.getState(), + sourceContext, + generation, + ); + if (!restored) return; } catch (error) { console.warn("[log-source] folder context restore failed after tab switch", { filePath, @@ -740,13 +789,15 @@ export async function switchToTab( } 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; } @@ -754,22 +805,28 @@ 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(logState, sourceContext, generation))) { + return; + } + await loadSelectedLogFile(filePath, source, generation); } -let folderRestoreGeneration = 0; - /** 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 { source } = sourceContext; const currentSource = logState.activeSource; const sourceChanged = @@ -778,22 +835,20 @@ async function restoreFolderContext( getLogSourcePath(currentSource) !== getLogSourcePath(source); if (!sourceChanged) { - return; + return true; } - const generation = ++folderRestoreGeneration; console.info("[log-source] restoring folder context", { sourceKind: source.kind, sourcePath: getLogSourcePath(source), }); const listing = await listLogSourceFolder(source); - if (generation !== folderRestoreGeneration) { - return; - } + if (!isCurrentTabSwitch(restoreGeneration)) return false; logState.setActiveSource(source); logState.setSourceEntries(listing.entries); logState.setBundleMetadata(listing.bundleMetadata ?? null); + return true; } /** @@ -1028,6 +1083,8 @@ export async function loadLogSource( source: LogSource, options: LoadLogSourceOptions = {} ): Promise { + // A new source load supersedes any pending tab restoration. + ++tabSwitchGeneration; const state = useLogStore.getState(); console.info("[log-source] loading source container", { diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index edddf30c6..56b9d7355 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -180,7 +180,7 @@ interface UiState { defaultShowInfoPane: boolean; confirmTabClose: boolean; showUpdateDialog: boolean; - dismissedDnsBannerPath: string | null; + dismissedDnsBannerPaths: string[]; recentSessions: string[]; graphApiEnabled: boolean; graphApiStatus: GraphApiPhase; @@ -250,7 +250,7 @@ interface UiState { setCollectionResult: (result: CollectionResult | null) => void; setShowCollectDiagnosticsDialog: (show: boolean) => void; setShowUpdateDialog: (show: boolean) => void; - setDismissedDnsBannerPath: (path: string | null) => void; + dismissDnsBannerPath: (path: string) => void; addRecentSession: (path: string) => void; clearRecentSessions: () => void; setGraphApiEnabled: (enabled: boolean) => void; @@ -346,7 +346,7 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, - dismissedDnsBannerPath: null, + dismissedDnsBannerPaths: [], recentSessions: [], graphApiEnabled: false, graphApiStatus: "disconnected", @@ -642,7 +642,12 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), - setDismissedDnsBannerPath: (path) => set({ dismissedDnsBannerPath: path }), + 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); From e8eaa1dadbe2fe60633d7f283e7d4f6a942dab51 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 22:14:53 -0400 Subject: [PATCH 09/30] fix: close async source loading races --- src-tauri/src/commands/file_ops.rs | 4 + src/hooks/use-parse-progress-listener.ts | 5 + src/lib/commands.ts | 10 +- src/lib/log-source.test.ts | 175 ++++++++++++++++- src/lib/log-source.ts | 231 +++++++++++++++++++---- src/stores/log-store.ts | 11 ++ src/stores/ui-store.test.ts | 42 +++++ src/stores/ui-store.ts | 11 ++ 8 files changed, 452 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index a6980249e..e9d888655 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -189,6 +189,7 @@ 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, @@ -201,6 +202,7 @@ struct ParseProgressPayload { #[tauri::command] pub fn parse_files_batch( paths: Vec, + request_id: u64, state: State<'_, AppState>, app: AppHandle, ) -> Result, crate::error::AppError> { @@ -244,6 +246,7 @@ pub fn parse_files_batch( let _ = app.emit( "parse-progress", ParseProgressPayload { + request_id, file_path: path.clone(), file_name, completed: done, @@ -266,6 +269,7 @@ pub fn parse_files_batch( let _ = app.emit( "parse-progress", ParseProgressPayload { + request_id, file_path: path.clone(), file_name, completed: done, diff --git a/src/hooks/use-parse-progress-listener.ts b/src/hooks/use-parse-progress-listener.ts index 7cd7fa869..a5d8a77f8 100644 --- a/src/hooks/use-parse-progress-listener.ts +++ b/src/hooks/use-parse-progress-listener.ts @@ -5,6 +5,8 @@ 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). */ @@ -59,6 +61,9 @@ export function useParseProgressListener() { if (state.folderLoadProgress === null) { return; } + if (p.requestId !== state.folderLoadRequestId) { + return; + } // Detect new batch: per-batch completed count resets to a lower value if (p.completed < prevBatchCompletedRef.current) { diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 0f1174d4b..c6109304b 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -293,9 +293,13 @@ export async function openLogFile(path: string): Promise { } /** 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 — eliminates N-1 round-trips. + * The request ID tags progress events so superseded batches are ignored. */ +export async function parseFilesBatch( + paths: string[], + requestId: number, +): Promise { + return invokeCommand("parse_files_batch", { paths, requestId }); } export async function listLogFolder( diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 11def7492..952986938 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -10,11 +10,18 @@ import type { EvidenceBundleMetadata } from "../types/evidence"; import { useLogStore, setCachedTabSnapshot, clearAllTabSnapshots } from "../stores/log-store"; import type { TabEntrySnapshot } from "./tab-snapshot-cache"; import { useUiStore } from "../stores/ui-store"; -import { loadLogSource, loadSelectedLogFile, switchToTab } from "./log-source"; +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(), @@ -107,7 +114,10 @@ describe("Device Inventory known-source routing", () => { 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(commands.openLogSourceFile).not.toHaveBeenCalled(); }); @@ -618,6 +628,62 @@ describe("switchToTab", () => { 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 pendingLoad; + + 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 }; @@ -665,3 +731,108 @@ describe("switchToTab", () => { }); }); }); + +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("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("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; + }); +}); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index a57c89012..70a62e470 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -265,8 +265,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"; @@ -289,6 +291,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({ @@ -319,9 +322,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); + 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`); @@ -335,6 +340,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) { @@ -379,6 +385,7 @@ async function loadFolderProgressive( } } + if (!isCurrentTabSwitch(loadGeneration)) return; // Apply the final aggregate state state.setActiveSource(source); state.setSourceEntries(folderEntries); @@ -424,8 +431,17 @@ async function recoverFromSelectedFileLoadFailure( source: LogSource, entries: FolderEntry[], selectedFilePath: string, - error: unknown + error: unknown, + loadGeneration: number, ): Promise { + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } const state = useLogStore.getState(); const { kind, message, accessDenied } = classifySourceError(error); @@ -436,6 +452,14 @@ async function recoverFromSelectedFileLoadFailure( }); await stopCurrentTailIfNeeded(null); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } clearSelectedFileState(source, entries); state.setSourceStatus({ @@ -560,7 +584,7 @@ export async function getKnownSourceMetadataById( export function loadSelectedLogFile( filePath: string, source: LogSource, -): Promise; +): Promise; export function loadSelectedLogFile( filePath: string, source: LogSource, @@ -571,8 +595,11 @@ export async function loadSelectedLogFile( source: LogSource, switchGeneration?: number, ): Promise { - if (!isCurrentTabSwitch(switchGeneration)) return null; + const operationGeneration = + switchGeneration ?? ++tabSwitchGeneration; + if (!isCurrentTabSwitch(operationGeneration)) 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); @@ -582,15 +609,15 @@ export async function loadSelectedLogFile( console.info("[log-source] loadSelectedLogFile registry from cache", { filePath }); const { getCachedRegistry, setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); - if (!isCurrentTabSwitch(switchGeneration)) return null; + if (!isCurrentTabSwitch(operationGeneration)) return null; let regData = getCachedRegistry(filePath); if (!regData) { regData = await parseRegistryFile(filePath); - if (!isCurrentTabSwitch(switchGeneration)) return null; + if (!isCurrentTabSwitch(operationGeneration)) return null; setCachedRegistry(filePath, regData); } - if (!isCurrentTabSwitch(switchGeneration)) return null; + if (!isCurrentTabSwitch(operationGeneration)) return null; state.setSelectedSourceFilePath(filePath); state.setSourceOpenMode("single-file"); @@ -656,7 +683,7 @@ export async function loadSelectedLogFile( filePath, }); - if (!isCurrentTabSwitch(switchGeneration)) return null; + if (!isCurrentTabSwitch(operationGeneration)) return null; state.setLoading(true); state.setSourceStatus({ kind: "loading", @@ -665,19 +692,19 @@ export async function loadSelectedLogFile( try { await stopCurrentTailIfNeeded(filePath); - if (!isCurrentTabSwitch(switchGeneration)) return null; + if (!isCurrentTabSwitch(operationGeneration)) return null; const result = await openLogFile(filePath); - if (!isCurrentTabSwitch(switchGeneration)) return result; + if (!isCurrentTabSwitch(operationGeneration)) return result; await applyParseResultToStore( source, result.filePath, result, - switchGeneration, + operationGeneration, ); return result; } finally { - if (isCurrentTabSwitch(switchGeneration)) { + if (isCurrentTabSwitch(operationGeneration)) { state.setLoading(false); } } @@ -696,6 +723,7 @@ export async function switchToTab( const currentPath = logState.openFilePath; const generation = ++tabSwitchGeneration; logState.setLoading(false); + logState.setFolderLoadProgress(null); // Already showing this file — invalidate any older pending switch and stop // its loading indicator. @@ -863,11 +891,15 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { await loadPathAsLogSource(paths[0], { fallbackToFolder: false }); return; } + const loadGeneration = ++tabSwitchGeneration; const state = useLogStore.getState(); + state.setFolderLoadProgress(null); + state.setFolderLoadRequestId(loadGeneration); // Clean up current state before starting the parse await stopCurrentTailIfNeeded(null); + if (!isCurrentTabSwitch(loadGeneration)) return; useFilterStore.getState().clearFilter(); state.setLoading(true); @@ -881,7 +913,8 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { const startTime = performance.now(); try { - const results = await parseFilesBatch(paths); + const results = await parseFilesBatch(paths, loadGeneration); + if (!isCurrentTabSwitch(loadGeneration)) return; const parseMs = Math.round(performance.now() - startTime); // Cache each file for instant tab switching @@ -940,6 +973,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { modifiedUnixMs: 0, })); + if (!isCurrentTabSwitch(loadGeneration)) return; state.setActiveSource(source); state.setSourceEntries(folderEntries); state.setSelectedSourceFilePath(null); @@ -967,8 +1001,10 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { detail: `Parsed in ${parseMs} ms (parallel).`, }); } finally { - state.setLoading(false); - state.setFolderLoadProgress(null); + if (isCurrentTabSwitch(loadGeneration)) { + state.setLoading(false); + state.setFolderLoadProgress(null); + } } } @@ -1051,7 +1087,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, }; @@ -1061,10 +1099,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 @@ -1074,18 +1114,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 = {} + options: LoadLogSourceOptions = {}, + existingGeneration?: number, ): Promise { - // A new source load supersedes any pending tab restoration. - ++tabSwitchGeneration; + // 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, @@ -1101,11 +1146,32 @@ export async function loadLogSource( try { if (source.kind === "file") { await stopCurrentTailIfNeeded(source.path); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } const result = await openLogSourceFile(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: result.filePath, + parseResult: result, + }; + } state.setSourceEntries([]); state.setBundleMetadata(null); - await applyParseResultToStore(source, result.filePath, result); + await applyParseResultToStore( + source, + result.filePath, + result, + loadGeneration, + ); return { source, @@ -1119,6 +1185,14 @@ export async function loadLogSource( if (source.kind === "folder") { const listing = await listLogSourceFolder(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } state.setActiveSource(source); state.setSourceEntries(listing.entries); @@ -1126,7 +1200,15 @@ export async function loadLogSource( if (!requestedFilePath) { await stopCurrentTailIfNeeded(null); - await loadFolderProgressive(source, listing.entries); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } + await loadFolderProgressive(source, listing.entries, loadGeneration); return { source, @@ -1136,13 +1218,26 @@ 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 { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } const metadata = knownSources.find((item) => item.id === source.sourceId); @@ -1152,11 +1247,32 @@ export async function loadLogSource( if (source.pathKind === "file") { await stopCurrentTailIfNeeded(source.defaultPath); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } const result = await openLogSourceFile(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: result.filePath, + parseResult: result, + }; + } state.setSourceEntries([]); state.setBundleMetadata(null); - await applyParseResultToStore(source, result.filePath, result); + await applyParseResultToStore( + source, + result.filePath, + result, + loadGeneration, + ); return { source, @@ -1167,6 +1283,14 @@ export async function loadLogSource( } const listing = await listLogSourceFolder(source); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } state.setActiveSource(source); state.setSourceEntries(listing.entries); @@ -1174,7 +1298,15 @@ export async function loadLogSource( if (!requestedFilePath) { await stopCurrentTailIfNeeded(null); - await loadFolderProgressive(source, listing.entries); + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } + await loadFolderProgressive(source, listing.entries, loadGeneration); return { source, @@ -1184,14 +1316,28 @@ export async function loadLogSource( }; } - return recoverOrLoadSelectedFolderFile(source, listing.entries, requestedFilePath); + return recoverOrLoadSelectedFolderFile( + source, + listing.entries, + requestedFilePath, + loadGeneration, + ); } catch (error) { + if (!isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: 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 @@ -1221,17 +1367,32 @@ 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 + requestedFilePath: string, + loadGeneration: number, ): Promise { try { - const result = await loadSelectedLogFile(requestedFilePath, source); + const result = await loadSelectedLogFile( + requestedFilePath, + source, + loadGeneration, + ); + if (!result || !isCurrentTabSwitch(loadGeneration)) { + return { + source, + entries: [], + selectedFilePath: null, + parseResult: null, + }; + } return { source, @@ -1240,6 +1401,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/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/ui-store.test.ts b/src/stores/ui-store.test.ts index dead355fe..9f182b88f 100644 --- a/src/stores/ui-store.test.ts +++ b/src/stores/ui-store.test.ts @@ -48,6 +48,48 @@ 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 () => { + 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 56b9d7355..709c85dba 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -273,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; @@ -673,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, }), From 87074a41faf897fcae1b685103c315befbb2016c Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 18 Aug 2026 22:39:11 -0400 Subject: [PATCH 10/30] fix: isolate dropped path failures and stale restores --- src/hooks/use-drag-drop.test.tsx | 26 ++++++++++++++++++++++++++ src/hooks/use-drag-drop.ts | 9 ++++++++- src/lib/log-source.ts | 16 ++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/hooks/use-drag-drop.test.tsx b/src/hooks/use-drag-drop.test.tsx index 2b6560f8d..563b7ca0b 100644 --- a/src/hooks/use-drag-drop.test.tsx +++ b/src/hooks/use-drag-drop.test.tsx @@ -127,6 +127,32 @@ describe("useDragDrop", () => { 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()); diff --git a/src/hooks/use-drag-drop.ts b/src/hooks/use-drag-drop.ts index 0f4d0e1a1..6d23f5f7a 100644 --- a/src/hooks/use-drag-drop.ts +++ b/src/hooks/use-drag-drop.ts @@ -30,7 +30,14 @@ export function useDragDrop() { if (activeWorkspace === "timeline") { for (const path of paths) { - await openPathForActiveWorkspace(path); + try { + await openPathForActiveWorkspace(path); + } catch (error) { + console.error("[drag-drop] failed to open dropped path", { + path, + error, + }); + } } return; } diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index 70a62e470..b54865e86 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -741,7 +741,13 @@ export async function switchToTab( // Restore sidebar context if (sourceContext && sourceContext.sourceKind !== "file") { - if (!(await restoreFolderContext(logState, sourceContext, generation))) { + if ( + !(await restoreFolderContext( + useLogStore.getState(), + sourceContext, + generation, + )) + ) { return; } } else if (sourceContext?.sourceKind === "file") { @@ -841,7 +847,13 @@ export async function switchToTab( } // Folder or known-source tab — restore sidebar then load the file - if (!(await restoreFolderContext(logState, sourceContext, generation))) { + if ( + !(await restoreFolderContext( + useLogStore.getState(), + sourceContext, + generation, + )) + ) { return; } await loadSelectedLogFile(filePath, source, generation); From b1a06a530693be7f8d260e042fd38d18d018ae9f Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 00:19:10 -0400 Subject: [PATCH 11/30] fix: harden IPC responses and stale source loads --- src/components/layout/FileSidebar.tsx | 12 +- .../layout/StatusBar.folder-progress.test.tsx | 76 ++++++++- src/hooks/use-app-actions.ts | 8 +- src/lib/commands.test.ts | 70 ++++++++ src/lib/commands.ts | 151 +++++++++++++++++- src/lib/log-source.test.ts | 33 +++- src/lib/log-source.ts | 91 +++-------- src/lib/session-restore.test.ts | 23 ++- src/lib/session-restore.ts | 8 +- 9 files changed, 385 insertions(+), 87 deletions(-) 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 index cdffc6ece..3e5832a06 100644 --- a/src/components/layout/StatusBar.folder-progress.test.tsx +++ b/src/components/layout/StatusBar.folder-progress.test.tsx @@ -1,6 +1,26 @@ -import { cleanup, render, screen } from "@testing-library/react"; +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; + 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[]; @@ -19,12 +39,26 @@ vi.mock("../../workspaces/event-log/evtx-store", () => ({ })); 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); @@ -34,12 +68,52 @@ describe("StatusBar folder parse progress", () => { 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, + 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, + 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/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index f92d920d3..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, @@ -459,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/lib/commands.test.ts b/src/lib/commands.test.ts index c5665ec82..13d8e10c6 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -12,7 +12,9 @@ import { graphCancelAuthentication, graphReserveInteractiveOperation, graphRequestMissingPermissions, + listLogFolder, openLogFile, + parseFilesBatch, revealInFileManager, } from "./commands"; import { readAccessDenied } from "./source-error"; @@ -80,6 +82,74 @@ 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)).resolves.toEqual([ + parseResult, + ]); + 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({ + sourceKind: "folder", + source: { kind: "folder", path: "C:\\Logs" }, + entries: [{ name: "App.log", path: "C:\\Logs\\App.log" }], + }); + + await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7)).rejects.toThrow( + "invalid response", + ); + await expect(listLogFolder("C:\\Logs")).rejects.toThrow("invalid response"); + }); +}); + function validGraphStatus() { return { isAuthenticated: true, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index c6109304b..512b4ec31 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -280,12 +280,147 @@ function normalizeCommandInvokeError( async function invokeCommand( commandName: string, args?: Record, + decoder?: (value: unknown) => T, ): Promise { + let response: unknown; try { - return await invoke(commandName, args); + response = await invoke(commandName, args); } catch (error) { throw normalizeCommandInvokeError(commandName, error); } + return decoder ? decoder(response) : (response as T); +} + +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)) + ); +} + +function invalidCommandResponse(commandName: string): never { + throw new Error(`Command '${commandName}' returned an invalid response.`); +} + +function decodeParseResults( + value: unknown, + commandName: string, +): ParseResult[] { + if (!Array.isArray(value) || !value.every(isParseResultResponse)) { + return invalidCommandResponse(commandName); + } + return value; +} + +function decodeFolderListingResult( + value: unknown, + commandName: string, +): FolderListingResult { + if (!isFolderListingResponse(value)) { + return invalidCommandResponse(commandName); + } + return value; } export async function openLogFile(path: string): Promise { @@ -299,13 +434,23 @@ export async function parseFilesBatch( paths: string[], requestId: number, ): Promise { - return invokeCommand("parse_files_batch", { paths, requestId }); + const commandName = "parse_files_batch"; + return invokeCommand( + commandName, + { paths, requestId }, + (value) => decodeParseResults(value, commandName), + ); } export async function listLogFolder( path: string, ): Promise { - return invokeCommand("list_log_folder", { path }); + const commandName = "list_log_folder"; + return invokeCommand( + commandName, + { path }, + (value) => decodeFolderListingResult(value, commandName), + ); } export async function inspectEvidenceBundle( diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 952986938..77c60f4a8 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -112,7 +112,8 @@ 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], @@ -126,7 +127,8 @@ 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(); } @@ -678,7 +680,7 @@ describe("switchToTab", () => { filePath: fileA, entries: [makeEntry(1, fileA, "AppEnforce line")], }); - await pendingLoad; + await expect(pendingLoad).resolves.toBeNull(); expect(useLogStore.getState().openFilePath).toBe(fileB); expect(useLogStore.getState().entries[0]?.message).toBe("CIAgent line"); @@ -800,6 +802,31 @@ describe("source loading progress ownership", () => { 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), + ); + }); + + 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")); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index b54865e86..e5b9e6549 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -530,10 +530,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"); @@ -1136,7 +1136,7 @@ export async function loadLogSource( source: LogSource, options: LoadLogSourceOptions = {}, existingGeneration?: number, -): Promise { +): 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. @@ -1159,21 +1159,11 @@ export async function loadLogSource( if (source.kind === "file") { await stopCurrentTailIfNeeded(source.path); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } const result = await openLogSourceFile(source); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: result.filePath, - parseResult: result, - }; + return null; } state.setSourceEntries([]); @@ -1198,12 +1188,7 @@ export async function loadLogSource( if (source.kind === "folder") { const listing = await listLogSourceFolder(source); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } state.setActiveSource(source); @@ -1213,14 +1198,12 @@ export async function loadLogSource( if (!requestedFilePath) { await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } await loadFolderProgressive(source, listing.entries, loadGeneration); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } return { source, @@ -1243,12 +1226,7 @@ export async function loadLogSource( ? state.knownSources : await refreshKnownLogSources(); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } const metadata = knownSources.find((item) => item.id === source.sourceId); @@ -1260,21 +1238,11 @@ export async function loadLogSource( if (source.pathKind === "file") { await stopCurrentTailIfNeeded(source.defaultPath); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } const result = await openLogSourceFile(source); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: result.filePath, - parseResult: result, - }; + return null; } state.setSourceEntries([]); @@ -1296,12 +1264,7 @@ export async function loadLogSource( const listing = await listLogSourceFolder(source); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } state.setActiveSource(source); @@ -1311,14 +1274,12 @@ export async function loadLogSource( if (!requestedFilePath) { await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } await loadFolderProgressive(source, listing.entries, loadGeneration); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } return { source, @@ -1336,12 +1297,7 @@ export async function loadLogSource( ); } catch (error) { if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } const { kind, message, accessDenied } = classifySourceError(error); @@ -1390,7 +1346,7 @@ async function recoverOrLoadSelectedFolderFile( entries: FolderEntry[], requestedFilePath: string, loadGeneration: number, -): Promise { +): Promise { try { const result = await loadSelectedLogFile( requestedFilePath, @@ -1398,12 +1354,7 @@ async function recoverOrLoadSelectedFolderFile( loadGeneration, ); if (!result || !isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } return { diff --git a/src/lib/session-restore.test.ts b/src/lib/session-restore.test.ts index dee1ef7dd..a52a27f5d 100644 --- a/src/lib/session-restore.test.ts +++ b/src/lib/session-restore.test.ts @@ -1,16 +1,24 @@ 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), + loadPathAsLogSource: vi.fn().mockResolvedValue({}), loadFilesAsLogSource: vi.fn().mockResolvedValue(undefined), })); +const restoredLoadResult = { + source: { kind: "file", path: "/tmp/app.log" } as const, + entries: [], + selectedFilePath: "/tmp/app.log", + parseResult: null, +}; + function sessionJson(clauses: unknown[]): string { return JSON.stringify({ version: 1, @@ -41,6 +49,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(undefined); // 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 +72,14 @@ 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([])); + vi.mocked(loadPathAsLogSource).mockResolvedValueOnce(null); + + await expect(restoreSession("/tmp/session.cmtrace")).resolves.toBeNull(); + expect(loadFilesAsLogSource).not.toHaveBeenCalled(); + }); + 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..ea74b85a0 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 }); From 8d4b4b9158e5db2136989b8182f2069dbb23c83d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 00:48:42 -0400 Subject: [PATCH 12/30] test: stabilize remaining PR 577 fixtures --- src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx | 2 ++ src/workspaces/intune/intune-story-fixtures.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx index d77ce28be..78714159c 100644 --- a/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx +++ b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx @@ -11,6 +11,7 @@ import type { DsregcmdWhfbPolicyEvidence, } from "./types"; import type { EventLogAnalysis, EventLogEntry } from "../../types/event-log"; +import { useUiStore } from "../../stores/ui-store"; vi.mock("../../hooks/use-app-actions", () => ({ useAppActions: () => ({ @@ -341,6 +342,7 @@ afterEach(() => { }); beforeEach(() => { + useUiStore.setState({ currentPlatform: "windows" }); useDsregcmdStore.getState().clear(); }); diff --git a/src/workspaces/intune/intune-story-fixtures.ts b/src/workspaces/intune/intune-story-fixtures.ts index cd074ef3d..8445209cc 100644 --- a/src/workspaces/intune/intune-story-fixtures.ts +++ b/src/workspaces/intune/intune-story-fixtures.ts @@ -253,6 +253,7 @@ export const LIVE_EMPTY_EVENT_LOG_ANALYSIS: EventLogAnalysis = { totalEntryCount: 0, errorEntryCount: 0, parsedFileCount: 0, + timestampBounds: null, liveQuery: { attemptedChannelCount: 2, successfulChannelCount: 1, From 6180f4210c67a27a1f00b97af80e046e3cf8dca4 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 01:15:20 -0400 Subject: [PATCH 13/30] fix: validate every frontend command response --- src/lib/commands.test.ts | 22 +- src/lib/commands.ts | 575 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 583 insertions(+), 14 deletions(-) diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 13d8e10c6..d208d24e4 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -136,6 +136,7 @@ describe("parse and folder IPC response validation", () => { 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", @@ -143,6 +144,9 @@ describe("parse and folder IPC response validation", () => { entries: [{ name: "App.log", path: "C:\\Logs\\App.log" }], }); + await expect(openLogFile("C:\\Logs\\App.log")).rejects.toThrow( + "invalid response", + ); await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7)).rejects.toThrow( "invalid response", ); @@ -171,7 +175,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", @@ -225,7 +236,14 @@ 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) diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 512b4ec31..f4a30e9a3 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -277,10 +277,11 @@ function normalizeCommandInvokeError( return normalizedError; } +type CommandDecoder = (value: unknown, commandName: string) => T; + async function invokeCommand( commandName: string, args?: Record, - decoder?: (value: unknown) => T, ): Promise { let response: unknown; try { @@ -288,7 +289,12 @@ async function invokeCommand( } catch (error) { throw normalizeCommandInvokeError(commandName, error); } - return decoder ? decoder(response) : (response as T); + + const decoder = COMMAND_DECODERS[commandName]; + if (!decoder) { + throw new Error(`No response decoder registered for '${commandName}'.`); + } + return decoder(response, commandName) as T; } function isCommandRecord(value: unknown): value is Record { @@ -403,6 +409,151 @@ 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: readonly WorkspaceId[] = [ + "log", + "intune", + "new-intune", + "dsregcmd", + "macos-diag", + "macos-jamf", + "deployment", + "event-log", + "esp-diagnostics", + "sccm", + "secureboot", + "sysmon", + "timeline", + "dns-dhcp", +]; + +function isWorkspaceIdValue(value: unknown): value is WorkspaceId { + return ( + typeof value === "string" && + WORKSPACE_IDS.some((workspaceId) => workspaceId === 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); +} + function decodeParseResults( value: unknown, commandName: string, @@ -413,6 +564,40 @@ function decodeParseResults( 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, @@ -434,23 +619,16 @@ export async function parseFilesBatch( paths: string[], requestId: number, ): Promise { - const commandName = "parse_files_batch"; - return invokeCommand( - commandName, + return invokeCommand( + "parse_files_batch", { paths, requestId }, - (value) => decodeParseResults(value, commandName), ); } export async function listLogFolder( path: string, ): Promise { - const commandName = "list_log_folder"; - return invokeCommand( - commandName, - { path }, - (value) => decodeFolderListingResult(value, commandName), - ); + return invokeCommand("list_log_folder", { path }); } export async function inspectEvidenceBundle( @@ -1241,6 +1419,7 @@ 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")}`; @@ -1281,3 +1460,375 @@ export async function runSecureBootRemediation(): Promise(value, commandName, { + stage: (field) => typeof field === "string", + dataSource: (field) => typeof field === "string", + scanState: isCommandRecord, + sessions: isCommandRecordArray, + timeline: isCommandRecordArray, + diagnostics: isCommandRecordArray, + scriptResult: isNullableCommandRecord, + }); +} + +type UntypedCommandDecoder = CommandDecoder; + +const COMMAND_DECODERS: Record = { + open_log_file: decodeParseResult, + parse_files_batch: decodeParseResults, + list_log_folder: decodeFolderListingResult, + 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: (field) => typeof field === "string", + summary: (field) => typeof field === "string", + }), + 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: (field) => typeof field === "string", + diagnosticsConfidence: (field) => typeof field === "string", + 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: (value, commandName) => + decodeRecordResponse(value, commandName, { + bundleRoot: (field) => typeof field === "string", + capturedAtUtc: (field) => typeof field === "string", + roles: isStringArray, + sources: isCommandRecordArray, + artifactCount: isFiniteCommandNumber, + retainedBytes: isFiniteCommandNumber, + }), + 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: (value, commandName) => + decodeRecordResponse(value, commandName, { + bundleRoot: (field) => typeof field === "string", + capturedAtUtc: (field) => typeof field === "string", + roles: isStringArray, + sources: isCommandRecordArray, + artifactCount: isFiniteCommandNumber, + retainedBytes: isFiniteCommandNumber, + }), + 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: (value, commandName) => + decodeRecordResponse(value, commandName, { + sessionId: (field) => typeof field === "string", + requestId: (field) => typeof field === "string", + sequence: isFiniteCommandNumber, + state: (field) => typeof field === "string", + snapshot: isCommandRecord, + }), + get_esp_diagnostics_session: (value, commandName) => + decodeRecordResponse(value, commandName, { + sessionId: (field) => typeof field === "string", + requestId: (field) => typeof field === "string", + sequence: isFiniteCommandNumber, + state: (field) => typeof field === "string", + snapshot: isCommandRecord, + }), + 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, +}; From 28352aec5ea10105f58b954ab5f34ebce78ddc56 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 01:29:50 -0400 Subject: [PATCH 14/30] fix: align E2E association status fixture --- e2e/fixtures/tauri-shim.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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, From dff078af12307edc40f6d4b12e568872235d80f7 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 01:33:17 -0400 Subject: [PATCH 15/30] fix: keep workspace decoder IDs exhaustive --- src/lib/commands.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/lib/commands.ts b/src/lib/commands.ts index f4a30e9a3..2f15d3e7d 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -512,27 +512,27 @@ function decodePathKindResponse( return value; } -const WORKSPACE_IDS: readonly WorkspaceId[] = [ - "log", - "intune", - "new-intune", - "dsregcmd", - "macos-diag", - "macos-jamf", - "deployment", - "event-log", - "esp-diagnostics", - "sccm", - "secureboot", - "sysmon", - "timeline", - "dns-dhcp", -]; +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" && - WORKSPACE_IDS.some((workspaceId) => workspaceId === value) + Object.prototype.hasOwnProperty.call(WORKSPACE_IDS, value) ); } From f2ef5b1b1eab50948c28ce527e91967bd0d2a893 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 01:41:30 -0400 Subject: [PATCH 16/30] fix: derive IPC responses from decoder registry --- src/lib/commands.ts | 227 +++++++++++++++++++------------------------- 1 file changed, 97 insertions(+), 130 deletions(-) diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 2f15d3e7d..261dfb248 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -279,10 +279,14 @@ function normalizeCommandInvokeError( type CommandDecoder = (value: unknown, commandName: string) => T; -async function invokeCommand( - commandName: string, +async function invokeCommand( + commandName: Name, + args?: Record, +): Promise>; +async function invokeCommand( + commandName: CommandName, args?: Record, -): Promise { +): Promise> { let response: unknown; try { response = await invoke(commandName, args); @@ -294,7 +298,7 @@ async function invokeCommand( if (!decoder) { throw new Error(`No response decoder registered for '${commandName}'.`); } - return decoder(response, commandName) as T; + return decoder(response, commandName); } function isCommandRecord(value: unknown): value is Record { @@ -390,9 +394,7 @@ function isFolderEntryResponse(value: unknown): boolean { ); } -function isFolderListingResponse( - value: unknown, -): value is FolderListingResult { +function isFolderListingResponse(value: unknown): value is FolderListingResult { return ( isCommandRecord(value) && isLogSourceKind(value.sourceKind) && @@ -425,10 +427,7 @@ function decodeRecordResponse( commandName: string, fields: Record = {}, ): T { - if ( - !isCommandRecord(value) || - !hasCommandFields(value, fields) - ) { + if (!isCommandRecord(value) || !hasCommandFields(value, fields)) { return invalidCommandResponse(commandName); } return value as T; @@ -454,7 +453,10 @@ function decodeStringArrayResponse( value: unknown, commandName: string, ): string[] { - if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + if ( + !Array.isArray(value) || + !value.every((item) => typeof item === "string") + ) { return invalidCommandResponse(commandName); } return value; @@ -564,10 +566,7 @@ function decodeParseResults( return value; } -function decodeParseResult( - value: unknown, - commandName: string, -): ParseResult { +function decodeParseResult(value: unknown, commandName: string): ParseResult { if (!isParseResultResponse(value)) { return invalidCommandResponse(commandName); } @@ -609,7 +608,7 @@ function decodeFolderListingResult( } 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). @@ -619,22 +618,19 @@ export async function parseFilesBatch( paths: string[], requestId: number, ): Promise { - return invokeCommand( - "parse_files_batch", - { paths, requestId }, - ); + return invokeCommand("parse_files_batch", { paths, requestId }); } export async function listLogFolder( path: string, ): Promise { - return invokeCommand("list_log_folder", { path }); + return invokeCommand("list_log_folder", { path }); } export async function inspectEvidenceBundle( path: string, ): Promise { - return invokeCommand("inspect_evidence_bundle", { + return invokeCommand("inspect_evidence_bundle", { path, }); } @@ -644,7 +640,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, @@ -654,11 +650,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( @@ -696,7 +692,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, }); } @@ -724,7 +720,7 @@ export async function startTail( nextId: number, nextLine: number, ): Promise { - return invokeCommand("start_tail", { + return invokeCommand("start_tail", { path, format, byteOffset, @@ -734,15 +730,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( @@ -750,7 +746,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, @@ -763,7 +759,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, @@ -774,20 +770,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, }); } @@ -796,37 +792,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, }); } @@ -838,34 +834,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, }); } @@ -873,17 +866,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 { @@ -894,11 +887,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 { @@ -930,38 +923,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 --- @@ -988,7 +974,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, @@ -998,14 +984,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, }); @@ -1024,7 +1010,7 @@ export async function exportEspSession( snapshot: EspDiagnosticsSnapshot, meta: EspSessionCaptureMeta, ): Promise { - return invokeCommand("export_esp_session", { + return invokeCommand("export_esp_session", { destination, snapshot, meta, @@ -1034,7 +1020,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, }); } @@ -1042,7 +1028,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, }); } @@ -1050,17 +1036,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, }); } @@ -1068,19 +1054,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) --- @@ -1306,62 +1292,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 --- @@ -1378,29 +1349,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, }); } @@ -1408,7 +1379,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, }); } @@ -1424,7 +1395,7 @@ export async function macosQueryUnifiedLog( 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, @@ -1438,27 +1409,21 @@ 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( @@ -1475,10 +1440,7 @@ function decodeSecureBootAnalysisResult( scriptResult: isNullableCommandRecord, }); } - -type UntypedCommandDecoder = CommandDecoder; - -const COMMAND_DECODERS: Record = { +const COMMAND_DECODERS = { open_log_file: decodeParseResult, parse_files_batch: decodeParseResults, list_log_folder: decodeFolderListingResult, @@ -1831,4 +1793,9 @@ const COMMAND_DECODERS: Record = { 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] +>; From 0aaa618a29976c0554b91a65fbb5350d7e0f943c Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 02:17:36 -0400 Subject: [PATCH 17/30] fix: harden workspace source loading --- src/components/timeline/TimelineWorkspace.tsx | 51 +++++++++-- src/hooks/use-app-menu.ts | 34 +++----- src/lib/log-source.test.ts | 44 +++++++--- src/lib/log-source.ts | 8 +- src/stores/timeline-store.test.ts | 7 ++ src/stores/timeline-store.ts | 7 ++ .../dsregcmd/DsregcmdWorkspace.test.tsx | 4 +- src/workspaces/event-log/evtx-store.ts | 2 + src/workspaces/event-log/index.ts | 13 ++- .../event-log/open-event-log-source.test.ts | 85 ++++++++++++++++++ .../event-log/open-event-log-source.ts | 51 ++++++----- src/workspaces/timeline/index.ts | 13 ++- .../timeline/open-timeline-source.test.ts | 87 +++++++++++++++++++ .../timeline/open-timeline-source.ts | 51 +++++++---- 14 files changed, 372 insertions(+), 85 deletions(-) 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/hooks/use-app-menu.ts b/src/hooks/use-app-menu.ts index 3d30058ed..b2fd1a440 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 { openTimelineSource } = 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 openTimelineSource({ 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/lib/log-source.test.ts b/src/lib/log-source.test.ts index 77c60f4a8..44c8d6103 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FolderEntry, + FolderListingResult, KnownSourceMetadata, LogEntry, LogSource, @@ -201,12 +202,6 @@ function evidenceBundleMetadata(): EvidenceBundleMetadata { }; } -type FolderListing = { - sourceKind: "folder"; - source: LogSource; - entries: FolderEntry[]; - bundleMetadata: null; -}; function deferred() { let resolvePromise: ((value: T) => void) | undefined; @@ -296,7 +291,7 @@ describe("switchToTab", () => { sourceOpenMode: "aggregate-folder", }); - const listing = deferred(); + const listing = deferred(); commands.listLogSourceFolder.mockReturnValue(listing.promise); const pending = switchToTab(fileB, { @@ -360,7 +355,7 @@ describe("switchToTab", () => { entries: snapshotFor(fileC, "Start line").entries, activeSource: { kind: "folder", path: "C:/Windows/CCM/Logs/Start" }, }); - const firstListing = deferred(); + const firstListing = deferred(); commands.listLogSourceFolder.mockReturnValueOnce(firstListing.promise); commands.listLogSourceFolder.mockResolvedValueOnce({ sourceKind: "folder", @@ -423,7 +418,7 @@ describe("switchToTab", () => { activeSource: { kind: "file", path: fileC }, }); - const staleListing = deferred(); + const staleListing = deferred(); commands.listLogSourceFolder.mockReturnValueOnce(staleListing.promise); const folderSwitch = switchToTab(fileA, { @@ -475,7 +470,7 @@ describe("switchToTab", () => { activeSource: { kind: "file", path: fileC }, }); - const listing = deferred(); + const listing = deferred(); const parsed = deferred(); commands.listLogSourceFolder.mockReturnValueOnce(listing.promise); commands.openLogFile.mockReturnValueOnce(parsed.promise); @@ -551,6 +546,35 @@ describe("switchToTab", () => { 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("invalidates a pending switch when reselecting the displayed tab", async () => { const fileSourceA: LogSource = { kind: "file", path: fileA }; const fileSourceB: LogSource = { kind: "file", path: fileB }; diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index e5b9e6549..d4f4846ec 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -63,8 +63,8 @@ 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 === undefined || generation === tabSwitchGeneration; +function isCurrentTabSwitch(generation: number): boolean { + return generation === tabSwitchGeneration; } export interface KnownSourceCatalogActionIds { @@ -166,7 +166,7 @@ async function applyParseResultToStore( source: LogSource, selectedFilePath: string, result: ParseResult, - switchGeneration?: number, + switchGeneration: number, ): Promise { if (!isCurrentTabSwitch(switchGeneration)) return; const state = useLogStore.getState(); @@ -695,7 +695,7 @@ export async function loadSelectedLogFile( if (!isCurrentTabSwitch(operationGeneration)) return null; const result = await openLogFile(filePath); - if (!isCurrentTabSwitch(operationGeneration)) return result; + if (!isCurrentTabSwitch(operationGeneration)) return null; await applyParseResultToStore( source, result.filePath, 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..9ad58f111 100644 --- a/src/stores/timeline-store.ts +++ b/src/stores/timeline-store.ts @@ -8,6 +8,7 @@ import type { interface TimelineState { bundle: TimelineBundle | null; + loadError: string | null; selectedIncidentId: number | null; brushRange: [number, number] | null; laneVisibility: Record; @@ -17,6 +18,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 +37,7 @@ const MAX_ENTRY_CACHE = 128; export const useTimelineStore = create((set, get) => ({ bundle: null, + loadError: null, selectedIncidentId: null, brushRange: null, laneVisibility: {}, @@ -49,6 +52,7 @@ export const useTimelineStore = create((set, get) => ({ }); set({ bundle: b, + loadError: null, selectedIncidentId: null, brushRange: null, laneVisibility, @@ -57,6 +61,9 @@ export const useTimelineStore = create((set, get) => ({ entryCache: new Map(), }); }, + setLoadError(error) { + set({ loadError: error }); + }, reset() { get().setBundle(null); diff --git a/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx index 78714159c..4df3b76fe 100644 --- a/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx +++ b/src/workspaces/dsregcmd/DsregcmdWorkspace.test.tsx @@ -52,7 +52,7 @@ function policyValue( }; } -function nullFacts(): DsregcmdFacts { +function fixtureFacts(): DsregcmdFacts { return { joinState: { azureAdJoined: true, @@ -227,7 +227,7 @@ function eventLogAnalysis(): EventLogAnalysis { function analysisResult(): DsregcmdAnalysisResult { return { - facts: nullFacts(), + facts: fixtureFacts(), derived: { joinType: "HybridEntraIdJoined", joinTypeLabel: "Hybrid Entra ID joined", diff --git a/src/workspaces/event-log/evtx-store.ts b/src/workspaces/event-log/evtx-store.ts index 304a1e13e..6e83d5e5f 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; @@ -448,6 +449,7 @@ export const useEvtxStore = create()((set, get) => ({ loadElapsedMs: performance.now() - startTime, }); }, + setLoadError: (error) => set({ 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 89002dbe4..94bc5a8a5 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -28,6 +28,17 @@ export const eventLogWorkspace: WorkspaceDefinition = { useUiStore.getState().ensureWorkspaceVisible("event-log", trigger); // Lazy: evtx-store registers Tauri event listeners at module load. const { openEventLogSource } = await import("./open-event-log-source"); - await openEventLogSource(source); + try { + await openEventLogSource(source); + } catch (error) { + console.error("[event-log] failed to open source", { + source, + trigger, + error, + }); + 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 index 7a4fb4c9b..a00de0ca1 100644 --- a/src/workspaces/event-log/open-event-log-source.test.ts +++ b/src/workspaces/event-log/open-event-log-source.test.ts @@ -28,6 +28,91 @@ describe("openEventLogSource", () => { "/tmp/Application.evtx", ]); }); + 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().parseFiles).not.toHaveBeenCalled(); + }); it("parses evtx files from a folder and ignores other names", async () => { vi.mocked(listLogFolder).mockResolvedValue({ diff --git a/src/workspaces/event-log/open-event-log-source.ts b/src/workspaces/event-log/open-event-log-source.ts index e7bbc8627..adac2f43e 100644 --- a/src/workspaces/event-log/open-event-log-source.ts +++ b/src/workspaces/event-log/open-event-log-source.ts @@ -11,32 +11,39 @@ function evtxPathsFromFolderEntries(entries: FolderEntry[]): string[] { export async function openEventLogSource(source: LogSource): Promise { const parseFiles = useEvtxStore.getState().parseFiles; - if (source.kind === "file") { - await parseFiles([source.path]); - return; - } + 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; + } - if (source.kind === "folder") { - const listing = await listLogFolder(source.path); + const listing = await listLogFolder(source.defaultPath); 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.", - ); + throw new Error("No .evtx files were found for that known source."); } 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."); + } catch (error) { + useEvtxStore.getState().setLoadError( + error instanceof Error ? error.message : String(error), + ); + throw error; } - await parseFiles(evtxPaths); } diff --git a/src/workspaces/timeline/index.ts b/src/workspaces/timeline/index.ts index ea0b185ef..ee94514a8 100644 --- a/src/workspaces/timeline/index.ts +++ b/src/workspaces/timeline/index.ts @@ -29,6 +29,17 @@ export const timelineWorkspace: WorkspaceDefinition = { onOpenSource: async (source, trigger) => { useUiStore.getState().ensureWorkspaceVisible("timeline", trigger); const { openTimelineSource } = await import("./open-timeline-source"); - await openTimelineSource(source); + try { + 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 index 254128905..17adc95c1 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -11,6 +11,30 @@ vi.mock("../../lib/commands", () => ({ vi.mock("../../components/timeline/hooks/useTimelineBundle", () => ({ buildTimelineFromSources: vi.fn(async () => ({ sources: [] })), })); +function deferred() { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { + promise, + resolve(value: T) { + if (!resolvePromise) { + throw new Error("Deferred promise resolver was not initialized"); + } + resolvePromise(value); + }, + }; +} + +type TimelineBuildResult = Awaited>; + +function bundleFor(paths: string[]): TimelineBuildResult { + return { + sources: paths.map((path, idx) => ({ path, idx })), + } as TimelineBuildResult; +} describe("openTimelineSource", () => { beforeEach(() => { @@ -88,5 +112,68 @@ describe("openTimelineSource", () => { await openTimelineSource({ kind: "folder", path: "/tmp/empty" }); expect(buildTimelineFromSources).not.toHaveBeenCalled(); }); + 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 index 5c384bb44..7b7520026 100644 --- a/src/workspaces/timeline/open-timeline-source.ts +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -18,29 +18,50 @@ function incomingFromListing(folderPath: string, entries: FolderEntry[]): string return hasIme ? [...childPaths, folderPath] : childPaths; } -export async function openTimelineSource(source: LogSource): Promise { - const existing = - useTimelineStore.getState().bundle?.sources.map((item) => item.path) ?? []; +let timelineOpenQueue: Promise = Promise.resolve(); - let incoming: string[] = []; - if (source.kind === "file") { - incoming = [source.path]; - } else if (source.kind === "folder") { - const listing = await listLogFolder(source.path); - incoming = incomingFromListing(source.path, listing.entries); - } else if (source.pathKind === "file") { - incoming = [source.defaultPath]; - } else { - const listing = await listLogFolder(source.defaultPath); - incoming = incomingFromListing(source.defaultPath, listing.entries); - } +function enqueueTimelineOpen(operation: () => Promise): Promise { + const queued = timelineOpenQueue.then(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); } + +export function openTimelineSource(source: LogSource): Promise { + return enqueueTimelineOpen(async () => { + let incoming: string[] = []; + if (source.kind === "file") { + incoming = [source.path]; + } else if (source.kind === "folder") { + const listing = await listLogFolder(source.path); + incoming = incomingFromListing(source.path, listing.entries); + } else if (source.pathKind === "file") { + incoming = [source.defaultPath]; + } else { + const listing = await listLogFolder(source.defaultPath); + incoming = incomingFromListing(source.defaultPath, listing.entries); + } + + await appendTimelineSources(incoming); + }); +} + +export function openTimelineFiles(paths: string[]): Promise { + return enqueueTimelineOpen(() => appendTimelineSources(paths)); +} From 306f8ccb0400ca566f76caa0703776854d982dc6 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 02:33:37 -0400 Subject: [PATCH 18/30] fix: preserve stale source load semantics --- src/hooks/use-app-menu.test.tsx | 48 +++++++++++++++++++++++++ src/hooks/use-app-menu.ts | 4 +++ src/lib/log-source.test.ts | 64 +++++++++++++++++++++++++++++++++ src/lib/log-source.ts | 39 ++++++++++---------- 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/src/hooks/use-app-menu.test.tsx b/src/hooks/use-app-menu.test.tsx index 0bac5a3b8..8c4cbf20f 100644 --- a/src/hooks/use-app-menu.test.tsx +++ b/src/hooks/use-app-menu.test.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useLogStore } from "../stores/log-store"; import { useUiStore } from "../stores/ui-store"; +import { useTimelineStore } from "../stores/timeline-store"; import type { WorkspaceId } from "../types/log"; import { useAppMenu } from "./use-app-menu"; import { useKeyboard } from "./use-keyboard"; @@ -18,6 +19,14 @@ const eventMocks = vi.hoisted(() => { return { state, listen, unlisten }; }); +const timelineMocks = vi.hoisted(() => ({ + openTimelineSource: vi.fn(async () => undefined), +})); + +const dialogMocks = vi.hoisted(() => ({ + open: vi.fn(async () => undefined as string | undefined), +})); + const actionMocks = vi.hoisted(() => ({ current: { commandState: { @@ -89,6 +98,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 +177,9 @@ describe("useAppMenu", () => { vi.mocked(invoke).mockReset().mockResolvedValue(undefined); eventMocks.state.callback = null; actionMocks.current.commandState = { ...initialCommandState }; + timelineMocks.openTimelineSource.mockReset().mockResolvedValue(undefined); + dialogMocks.open.mockReset().mockResolvedValue(undefined); + useTimelineStore.getState().reset(); useUiStore.setState({ activeWorkspace: "log", activeView: "log", @@ -348,6 +366,36 @@ describe("useAppMenu", () => { expect.any(Object), ); }); + it("replaces the timeline for native New Timeline folder opens", async () => { + useTimelineStore.setState({ + bundle: { + id: "existing", + sources: [], + timeRangeMs: [0, 0], + totalEntries: 0, + incidents: [], + deniedGuids: [], + errors: [], + tunables: { + overlapWindowMs: 5000, + minSourceCount: 2, + maxIncidentSpanMs: 60000, + enabledSignalKinds: ["errorSeverity"], + }, + }, + }); + 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.openTimelineSource).toHaveBeenCalledWith({ + kind: "folder", + path: "C:/Evidence/NewTimeline", + }); + expect(useTimelineStore.getState().bundle).toBeNull(); + }); it("opens a recent entry in its recorded workspace", async () => { renderHook(() => useAppMenu()); diff --git a/src/hooks/use-app-menu.ts b/src/hooks/use-app-menu.ts index b2fd1a440..281d8a9e5 100644 --- a/src/hooks/use-app-menu.ts +++ b/src/hooks/use-app-menu.ts @@ -366,6 +366,10 @@ export function useAppMenu() { const { openTimelineSource } = await import( "../workspaces/timeline/open-timeline-source" ); + const { useTimelineStore } = await import( + "../stores/timeline-store" + ); + useTimelineStore.getState().setBundle(null); useUiStore.getState().ensureWorkspaceVisible( "timeline", "native-menu.timeline-new-from-folder", diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 44c8d6103..7c9cdcb8a 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -8,6 +8,7 @@ import type { ParseResult, } from "../types/log"; 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"; @@ -258,6 +259,45 @@ describe("switchToTab", () => { ); expect(useLogStore.getState().isLoading).toBe(false); }); + 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")); @@ -792,6 +832,30 @@ describe("source loading progress ownership", () => { 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"; + let rejectOpenLogFile: (error: unknown) => void = () => undefined; + const pendingOpenLogFile = new Promise((_, reject) => { + rejectOpenLogFile = reject; + }); + commands.openLogFile.mockReturnValueOnce(pendingOpenLogFile); + 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 }); + rejectOpenLogFile(new Error("selected file failed")); + + await expect(staleLoad).resolves.toBeNull(); + }); it("ignores a path probe superseded by a newer source load", async () => { useLogStore.setState({ diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index d4f4846ec..abb0368e6 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -167,8 +167,8 @@ async function applyParseResultToStore( selectedFilePath: string, result: ParseResult, switchGeneration: number, -): Promise { - if (!isCurrentTabSwitch(switchGeneration)) return; +): 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") { @@ -180,7 +180,7 @@ async function applyParseResultToStore( throw err; } const { setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); - if (!isCurrentTabSwitch(switchGeneration)) return; + if (!isCurrentTabSwitch(switchGeneration)) return false; state.setActiveSource(source); state.setSelectedSourceFilePath(selectedFilePath); @@ -209,7 +209,7 @@ async function applyParseResultToStore( useUiStore.getState().openTab(selectedFilePath, fileName, buildTabSourceContext(source), "registry"); setCachedRegistry(selectedFilePath, registryData); useRegistryStore.getState().setRegistryData(registryData); - return; + return true; } state.setActiveSource(source); @@ -245,6 +245,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 { @@ -433,14 +434,9 @@ async function recoverFromSelectedFileLoadFailure( selectedFilePath: string, error: unknown, loadGeneration: number, -): Promise { +): Promise { if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } const state = useLogStore.getState(); const { kind, message, accessDenied } = classifySourceError(error); @@ -453,12 +449,7 @@ async function recoverFromSelectedFileLoadFailure( await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) { - return { - source, - entries: [], - selectedFilePath: null, - parseResult: null, - }; + return null; } clearSelectedFileState(source, entries); @@ -696,13 +687,13 @@ export async function loadSelectedLogFile( const result = await openLogFile(filePath); if (!isCurrentTabSwitch(operationGeneration)) return null; - await applyParseResultToStore( + const applied = await applyParseResultToStore( source, result.filePath, result, operationGeneration, ); - return result; + return applied ? result : null; } finally { if (isCurrentTabSwitch(operationGeneration)) { state.setLoading(false); @@ -1168,12 +1159,15 @@ export async function loadLogSource( state.setSourceEntries([]); state.setBundleMetadata(null); - await applyParseResultToStore( + const applied = await applyParseResultToStore( source, result.filePath, result, loadGeneration, ); + if (!applied) { + return null; + } return { source, @@ -1247,12 +1241,15 @@ export async function loadLogSource( state.setSourceEntries([]); state.setBundleMetadata(null); - await applyParseResultToStore( + const applied = await applyParseResultToStore( source, result.filePath, result, loadGeneration, ); + if (!applied) { + return null; + } return { source, From 932857e4ff97a9f78de3672b21a2debec03675fa Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 03:07:56 -0400 Subject: [PATCH 19/30] fix: close review gaps in source loading and timeline actions --- src-tauri/src/commands/file_ops.rs | 4 + src/components/dialogs/UpdateDialog.test.tsx | 22 +++ src/components/dialogs/UpdateDialog.tsx | 9 +- .../layout/StatusBar.folder-progress.test.tsx | 3 + src/hooks/use-app-menu.test.tsx | 30 +--- src/hooks/use-app-menu.ts | 8 +- src/hooks/use-modal-focus.ts | 20 +++ .../use-parse-progress-listener.test.tsx | 165 ++++++++++++++++++ src/hooks/use-parse-progress-listener.ts | 103 +++++++---- src/lib/commands.test.ts | 4 +- src/lib/commands.ts | 12 +- src/lib/log-source.test.ts | 2 + src/lib/log-source.ts | 4 +- .../timeline/open-timeline-source.test.ts | 89 +++++++++- .../timeline/open-timeline-source.ts | 46 +++-- 15 files changed, 434 insertions(+), 87 deletions(-) create mode 100644 src/hooks/use-parse-progress-listener.test.tsx diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index e9d888655..673fa509c 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -193,6 +193,7 @@ struct ParseProgressPayload { file_path: String, file_name: String, completed: u32, + global_completed: u32, total: u32, entries: u32, file_size: u64, @@ -203,6 +204,7 @@ struct ParseProgressPayload { pub fn parse_files_batch( paths: Vec, request_id: u64, + completed_offset: u32, state: State<'_, AppState>, app: AppHandle, ) -> Result, crate::error::AppError> { @@ -250,6 +252,7 @@ pub fn parse_files_batch( 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, @@ -273,6 +276,7 @@ pub fn parse_files_batch( 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/UpdateDialog.test.tsx b/src/components/dialogs/UpdateDialog.test.tsx index 17d759dd2..94b50f892 100644 --- a/src/components/dialogs/UpdateDialog.test.tsx +++ b/src/components/dialogs/UpdateDialog.test.tsx @@ -89,6 +89,28 @@ describe("UpdateDialog", () => { 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 }); diff --git a/src/components/dialogs/UpdateDialog.tsx b/src/components/dialogs/UpdateDialog.tsx index adec9706d..63adb21cb 100644 --- a/src/components/dialogs/UpdateDialog.tsx +++ b/src/components/dialogs/UpdateDialog.tsx @@ -30,8 +30,15 @@ export function UpdateDialog({ onSkipVersion, }: UpdateDialogProps) { const dialogRef = useRef(null); + const focusKey = isChecking + ? "checking" + : isDownloading + ? "downloading" + : updateInfo?.available + ? "available" + : "idle"; - useModalFocus(isOpen, dialogRef); + useModalFocus(isOpen, dialogRef, undefined, focusKey); useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { diff --git a/src/components/layout/StatusBar.folder-progress.test.tsx b/src/components/layout/StatusBar.folder-progress.test.tsx index 3e5832a06..b3649a907 100644 --- a/src/components/layout/StatusBar.folder-progress.test.tsx +++ b/src/components/layout/StatusBar.folder-progress.test.tsx @@ -7,6 +7,7 @@ type ProgressPayload = { fileName: string; completed: number; total: number; + globalCompleted: number; entries: number; fileSize: number; parseMs: number; @@ -86,6 +87,7 @@ describe("StatusBar folder parse progress", () => { filePath: "stale.log", fileName: "stale.log", completed: 9, + globalCompleted: 9, total: 10, entries: 1, fileSize: 1, @@ -103,6 +105,7 @@ describe("StatusBar folder parse progress", () => { filePath: "Accepted.log", fileName: "Accepted.log", completed: 4, + globalCompleted: 4, total: 10, entries: 1, fileSize: 1, diff --git a/src/hooks/use-app-menu.test.tsx b/src/hooks/use-app-menu.test.tsx index 8c4cbf20f..8e58a2167 100644 --- a/src/hooks/use-app-menu.test.tsx +++ b/src/hooks/use-app-menu.test.tsx @@ -3,7 +3,6 @@ import { invoke } from "@tauri-apps/api/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useLogStore } from "../stores/log-store"; import { useUiStore } from "../stores/ui-store"; -import { useTimelineStore } from "../stores/timeline-store"; import type { WorkspaceId } from "../types/log"; import { useAppMenu } from "./use-app-menu"; import { useKeyboard } from "./use-keyboard"; @@ -20,7 +19,7 @@ const eventMocks = vi.hoisted(() => { }); const timelineMocks = vi.hoisted(() => ({ - openTimelineSource: vi.fn(async () => undefined), + replaceTimelineSource: vi.fn(async () => undefined), })); const dialogMocks = vi.hoisted(() => ({ @@ -177,9 +176,10 @@ describe("useAppMenu", () => { vi.mocked(invoke).mockReset().mockResolvedValue(undefined); eventMocks.state.callback = null; actionMocks.current.commandState = { ...initialCommandState }; - timelineMocks.openTimelineSource.mockReset().mockResolvedValue(undefined); + timelineMocks.replaceTimelineSource + .mockReset() + .mockResolvedValue(undefined); dialogMocks.open.mockReset().mockResolvedValue(undefined); - useTimelineStore.getState().reset(); useUiStore.setState({ activeWorkspace: "log", activeView: "log", @@ -366,35 +366,17 @@ describe("useAppMenu", () => { expect.any(Object), ); }); - it("replaces the timeline for native New Timeline folder opens", async () => { - useTimelineStore.setState({ - bundle: { - id: "existing", - sources: [], - timeRangeMs: [0, 0], - totalEntries: 0, - incidents: [], - deniedGuids: [], - errors: [], - tunables: { - overlapWindowMs: 5000, - minSourceCount: 2, - maxIncidentSpanMs: 60000, - enabledSignalKinds: ["errorSeverity"], - }, - }, - }); + 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.openTimelineSource).toHaveBeenCalledWith({ + expect(timelineMocks.replaceTimelineSource).toHaveBeenCalledWith({ kind: "folder", path: "C:/Evidence/NewTimeline", }); - expect(useTimelineStore.getState().bundle).toBeNull(); }); it("opens a recent entry in its recorded workspace", async () => { diff --git a/src/hooks/use-app-menu.ts b/src/hooks/use-app-menu.ts index 281d8a9e5..9317eb0d0 100644 --- a/src/hooks/use-app-menu.ts +++ b/src/hooks/use-app-menu.ts @@ -363,18 +363,14 @@ export function useAppMenu() { if (!folder || Array.isArray(folder)) return; const folderPath = folder as string; try { - const { openTimelineSource } = await import( + const { replaceTimelineSource } = await import( "../workspaces/timeline/open-timeline-source" ); - const { useTimelineStore } = await import( - "../stores/timeline-store" - ); - useTimelineStore.getState().setBundle(null); useUiStore.getState().ensureWorkspaceVisible( "timeline", "native-menu.timeline-new-from-folder", ); - await openTimelineSource({ kind: "folder", path: folderPath }); + await replaceTimelineSource({ kind: "folder", path: folderPath }); } catch (error) { console.error("[app-menu] failed to build timeline from folder", { folderPath, diff --git a/src/hooks/use-modal-focus.ts b/src/hooks/use-modal-focus.ts index 9095aca68..0863390a2 100644 --- a/src/hooks/use-modal-focus.ts +++ b/src/hooks/use-modal-focus.ts @@ -13,6 +13,7 @@ export function useModalFocus( isOpen: boolean, surfaceRef: RefObject, initialFocusRef?: RefObject, + focusKey?: string | number | null, ): void { useEffect(() => { if (!isOpen) return; @@ -34,6 +35,25 @@ export function useModalFocus( } }; }, [initialFocusRef, isOpen, surfaceRef]); + useEffect(() => { + if (!isOpen || focusKey == null) return; + + const surface = surfaceRef.current; + if (!surface) return; + + const active = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + if (active && surface.contains(active)) return; + + const preferred = initialFocusRef?.current; + const target = + preferred && !preferred.hasAttribute("disabled") + ? preferred + : surface.querySelector(FOCUSABLE_SELECTOR) ?? surface; + target.focus(); + }, [focusKey, initialFocusRef, isOpen, surfaceRef]); useEffect(() => { if (!isOpen) return; 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..270e88ebd --- /dev/null +++ b/src/hooks/use-parse-progress-listener.test.tsx @@ -0,0 +1,165 @@ +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 the monotonic counter for a new request", 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: "", + }); + }); + + act(() => { + 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 a5d8a77f8..05c798a83 100644 --- a/src/hooks/use-parse-progress-listener.ts +++ b/src/hooks/use-parse-progress-listener.ts @@ -13,74 +13,107 @@ interface ParseProgressPayload { 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; } - if (p.requestId !== state.folderLoadRequestId) { + if (event.payload.requestId !== state.folderLoadRequestId) { 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; + const globalTotal = state.folderLoadTotalFiles; + if ( + globalTotal === null || + event.payload.globalCompleted > globalTotal || + event.payload.globalCompleted <= globalCompletedRef.current + ) { + return; } - prevBatchCompletedRef.current = p.completed; - - const globalCompleted = globalCompletedRef.current + p.completed; - const globalTotal = state.folderLoadTotalFiles ?? p.total; + 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 d208d24e4..5424d211c 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -128,7 +128,7 @@ describe("parse and folder IPC response validation", () => { .mockResolvedValueOnce([parseResult]) .mockResolvedValueOnce(folderListing); - await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7)).resolves.toEqual([ + await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).resolves.toEqual([ parseResult, ]); await expect(listLogFolder("C:\\Logs")).resolves.toEqual(folderListing); @@ -147,7 +147,7 @@ describe("parse and folder IPC response validation", () => { await expect(openLogFile("C:\\Logs\\App.log")).rejects.toThrow( "invalid response", ); - await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7)).rejects.toThrow( + await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).rejects.toThrow( "invalid response", ); await expect(listLogFolder("C:\\Logs")).rejects.toThrow("invalid response"); diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 261dfb248..bfa94106b 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -612,13 +612,19 @@ export async function openLogFile(path: string): Promise { } /** 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. - * The request ID tags progress events so superseded batches are ignored. */ + * 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 }); + return invokeCommand("parse_files_batch", { + paths, + requestId, + completedOffset, + }); } export async function listLogFolder( diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 7c9cdcb8a..f33bea58c 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -120,6 +120,7 @@ describe("Device Inventory known-source routing", () => { expect(commands.parseFilesBatch).toHaveBeenCalledWith( [folderEntries[0].path], expect.any(Number), + expect.any(Number), ); expect(commands.openLogSourceFile).not.toHaveBeenCalled(); }); @@ -899,6 +900,7 @@ describe("source loading progress ownership", () => { expect(commands.parseFilesBatch).toHaveBeenCalledWith( [sourceEntries[0].path], expect.any(Number), + expect.any(Number), ); }); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index abb0368e6..13bfd8f95 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -326,7 +326,7 @@ async function loadFolderProgressive( if (!isCurrentTabSwitch(loadGeneration)) return; const batchStart = performance.now(); - const batchResults = await parseFilesBatch(batch, loadGeneration); + const batchResults = await parseFilesBatch(batch, loadGeneration, offset); if (!isCurrentTabSwitch(loadGeneration)) return; const batchMs = Math.round(performance.now() - batchStart); @@ -916,7 +916,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { const startTime = performance.now(); try { - const results = await parseFilesBatch(paths, loadGeneration); + const results = await parseFilesBatch(paths, loadGeneration, 0); if (!isCurrentTabSwitch(loadGeneration)) return; const parseMs = Math.round(performance.now() - startTime); diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index 17adc95c1..3fe4cdb3d 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { listLogFolder } from "../../lib/commands"; import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; import { useTimelineStore } from "../../stores/timeline-store"; -import { openTimelineSource } from "./open-timeline-source"; +import { openTimelineSource, replaceTimelineSource } from "./open-timeline-source"; vi.mock("../../lib/commands", () => ({ listLogFolder: vi.fn(), @@ -48,6 +48,55 @@ describe("openTimelineSource", () => { { 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("unions folder files with an existing timeline", async () => { useTimelineStore.setState({ @@ -68,6 +117,44 @@ describe("openTimelineSource", () => { { path: "/tmp/logs/a.log" }, ]); }); + it("replaces pending timeline appends instead of merging stale sources", async () => { + useTimelineStore.setState({ + bundle: { sources: [{ path: "/tmp/existing.log" }] }, + } as never); + 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("adds the folder itself when IME logs are present", async () => { vi.mocked(listLogFolder).mockResolvedValue({ diff --git a/src/workspaces/timeline/open-timeline-source.ts b/src/workspaces/timeline/open-timeline-source.ts index 7b7520026..67b1a08e1 100644 --- a/src/workspaces/timeline/open-timeline-source.ts +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -43,22 +43,42 @@ async function appendTimelineSources(incoming: string[]): Promise { 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 () => { - let incoming: string[] = []; - if (source.kind === "file") { - incoming = [source.path]; - } else if (source.kind === "folder") { - const listing = await listLogFolder(source.path); - incoming = incomingFromListing(source.path, listing.entries); - } else if (source.pathKind === "file") { - incoming = [source.defaultPath]; - } else { - const listing = await listLogFolder(source.defaultPath); - incoming = incomingFromListing(source.defaultPath, listing.entries); - } + await appendTimelineSources(await incomingFromSource(source)); + }); +} - await appendTimelineSources(incoming); +export function replaceTimelineSource(source: LogSource): Promise { + return enqueueTimelineOpen(async () => { + await replaceTimelineSources(await incomingFromSource(source)); }); } From 16fc8adabae16d2fe463f567695b1f00c6f79fe3 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 03:31:44 -0400 Subject: [PATCH 20/30] fix: reset replacement and progress ownership synchronously --- .../use-parse-progress-listener.test.tsx | 10 +-- src/hooks/use-parse-progress-listener.ts | 11 +++- .../timeline/open-timeline-source.test.ts | 64 +++++++++++++++---- .../timeline/open-timeline-source.ts | 18 ++++-- 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/src/hooks/use-parse-progress-listener.test.tsx b/src/hooks/use-parse-progress-listener.test.tsx index 270e88ebd..395fd77fc 100644 --- a/src/hooks/use-parse-progress-listener.test.tsx +++ b/src/hooks/use-parse-progress-listener.test.tsx @@ -7,10 +7,7 @@ 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, - ) => { + async (_event: string, listener: (event: { payload: unknown }) => void) => { eventMocks.listener = listener; return eventMocks.unlisten; }, @@ -139,7 +136,7 @@ describe("useParseProgressListener", () => { expect(useLogStore.getState().folderLoadCompletedFiles).toBe(3); }); - it("resets the monotonic counter for a new request", async () => { + it("resets ownership before a new request event arrives", async () => { activateProgress(7); renderHook(() => useParseProgressListener()); await waitFor(() => expect(eventMocks.listener).not.toBeNull()); @@ -153,9 +150,6 @@ describe("useParseProgressListener", () => { folderLoadCompletedFiles: 0, folderLoadCurrentFile: "", }); - }); - - act(() => { eventMocks.listener?.({ payload: progress({ requestId: 8, globalCompleted: 1 }), }); diff --git a/src/hooks/use-parse-progress-listener.ts b/src/hooks/use-parse-progress-listener.ts index 05c798a83..fd11e5748 100644 --- a/src/hooks/use-parse-progress-listener.ts +++ b/src/hooks/use-parse-progress-listener.ts @@ -63,9 +63,7 @@ export function useParseProgressListener() { const isFolderLoading = useLogStore( (state) => state.folderLoadProgress !== null, ); - const folderLoadRequestId = useLogStore( - (state) => state.folderLoadRequestId, - ); + const folderLoadRequestId = useLogStore((state) => state.folderLoadRequestId); const globalCompletedRef = useRef(0); const trackedRequestIdRef = useRef(null); @@ -98,6 +96,13 @@ export function useParseProgressListener() { return; } + // 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 || diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index 3fe4cdb3d..96d8d7f22 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { listLogFolder } from "../../lib/commands"; import { buildTimelineFromSources } from "../../components/timeline/hooks/useTimelineBundle"; import { useTimelineStore } from "../../stores/timeline-store"; -import { openTimelineSource, replaceTimelineSource } from "./open-timeline-source"; +import { + openTimelineSource, + replaceTimelineSource, +} from "./open-timeline-source"; vi.mock("../../lib/commands", () => ({ listLogFolder: vi.fn(), @@ -106,8 +109,20 @@ describe("openTimelineSource", () => { 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 }, + { + 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, + }, ], }); @@ -155,6 +170,37 @@ describe("openTimelineSource", () => { 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: { sources: [{ path: "/tmp/existing.log" }] }, + } as never); + 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: { sources: [{ path: "/tmp/existing.log" }] }, + } as never); + 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(); + }); it("adds the folder itself when IME logs are present", async () => { vi.mocked(listLogFolder).mockResolvedValue({ @@ -252,15 +298,11 @@ describe("openTimelineSource", () => { { path: "/tmp/other.log" }, ]); - secondBuild.resolve( - bundleFor(["/tmp/logs/a.log", "/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", - ]); + 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 index 67b1a08e1..03cd3f768 100644 --- a/src/workspaces/timeline/open-timeline-source.ts +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -3,8 +3,13 @@ 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); +function incomingFromListing( + folderPath: string, + entries: FolderEntry[], +): string[] { + const childPaths = entries + .filter((entry) => !entry.isDir) + .map((entry) => entry.path); if (childPaths.length === 0) { return []; } @@ -37,9 +42,11 @@ async function appendTimelineSources(incoming: string[]): Promise { const existing = useTimelineStore.getState().bundle?.sources.map((item) => item.path) ?? []; - const merged = Array.from(new Set([...existing, ...incoming])).map((path) => ({ - path, - })); + const merged = Array.from(new Set([...existing, ...incoming])).map( + (path) => ({ + path, + }), + ); await buildTimelineFromSources(merged); } @@ -78,6 +85,7 @@ export function openTimelineSource(source: LogSource): Promise { export function replaceTimelineSource(source: LogSource): Promise { return enqueueTimelineOpen(async () => { + useTimelineStore.getState().setBundle(null); await replaceTimelineSources(await incomingFromSource(source)); }); } From 4262a77de87090d6cc1417c1c72ee107ce1d735a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 04:00:04 -0400 Subject: [PATCH 21/30] fix: close remaining review findings --- .../timeline/hooks/useTimelineBundle.ts | 4 +- src/lib/commands.test.ts | 92 ++++++++- src/lib/commands.ts | 195 +++++++++++++++--- src/lib/log-source.ts | 9 - .../event-log/open-event-log-source.test.ts | 7 + .../intune/createIntuneOnOpenSource.test.ts | 21 ++ src/workspaces/timeline/index.ts | 2 +- .../timeline/open-timeline-source.test.ts | 61 ++++-- .../timeline/open-timeline-source.ts | 5 +- 9 files changed, 322 insertions(+), 74 deletions(-) diff --git a/src/components/timeline/hooks/useTimelineBundle.ts b/src/components/timeline/hooks/useTimelineBundle.ts index 8203e61f1..0a5772147 100644 --- a/src/components/timeline/hooks/useTimelineBundle.ts +++ b/src/components/timeline/hooks/useTimelineBundle.ts @@ -1,12 +1,12 @@ 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 }); + const bundle = await buildTimeline(sources); useTimelineStore.getState().setBundle(bundle); return bundle; } diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 5424d211c..7347485d1 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -12,6 +12,7 @@ import { graphCancelAuthentication, graphReserveInteractiveOperation, graphRequestMissingPermissions, + buildTimeline, listLogFolder, openLogFile, parseFilesBatch, @@ -128,9 +129,9 @@ describe("parse and folder IPC response validation", () => { .mockResolvedValueOnce([parseResult]) .mockResolvedValueOnce(folderListing); - await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).resolves.toEqual([ - parseResult, - ]); + await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).resolves.toEqual( + [parseResult], + ); await expect(listLogFolder("C:\\Logs")).resolves.toEqual(folderListing); }); @@ -154,6 +155,77 @@ describe("parse and folder IPC response validation", () => { }); }); +function validTimelineBundle() { + return { + id: "timeline-1", + sources: [ + { + idx: 0, + kind: "intuneEvents", + path: "C:\\Logs", + displayName: "Logs", + color: "#2563eb", + entryCount: 1, + }, + ], + timeRangeMs: [100, 200], + totalEntries: 1, + 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 validGraphStatus() { return { isAuthenticated: true, @@ -249,7 +321,9 @@ describe("SCCM product-path IPC boundary", () => { .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); @@ -257,9 +331,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 bfa94106b..44e69855d 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -49,6 +49,7 @@ import type { SccmAdvancedCaptureCapability, SccmEnvironmentDiscovery, } from "../workspaces/sccm/types"; +import type { TimelineBundle } from "../types/timeline"; export interface FileAssociationPromptStatus { supported: boolean; @@ -406,6 +407,130 @@ function isFolderListingResponse(value: unknown): value is FolderListingResult { isCommandRecord(value.bundleMetadata)) ); } +const TIMELINE_PARSER_KINDS = new Set([ + "ccm", + "simple", + "timestamped", + "plain", + "iisW3c", + "panther", + "cbs", + "dism", + "reportingEvents", + "msi", + "psadtLegacy", + "intuneMacOs", + "intuneDeviceInventory", + "dhcp", + "burn", + "patchMyPcDetection", + "registry", + "secureBootLog", + "dnsDebug", + "dnsAudit", + "cmtLog", + "companyPortal", +]); + +const TIMELINE_SIGNAL_KINDS = new Set([ + "errorSeverity", + "knownErrorCode", + "imeFailed", +]); + +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.`); @@ -633,6 +758,12 @@ export async function listLogFolder( 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 { @@ -1446,10 +1577,36 @@ function decodeSecureBootAnalysisResult( 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", @@ -1572,15 +1729,7 @@ const COMMAND_DECODERS = { issues: isCommandRecordArray, advancedSources: isCommandRecordArray, }), - capture_sccm_diagnostics: (value, commandName) => - decodeRecordResponse(value, commandName, { - bundleRoot: (field) => typeof field === "string", - capturedAtUtc: (field) => typeof field === "string", - roles: isStringArray, - sources: isCommandRecordArray, - artifactCount: isFiniteCommandNumber, - retainedBytes: isFiniteCommandNumber, - }), + capture_sccm_diagnostics: decodeSccmCaptureResult, authorize_sccm_advanced_capture: (value, commandName) => decodeRecordResponse(value, commandName, { capabilityHandle: (field) => typeof field === "string", @@ -1591,15 +1740,7 @@ const COMMAND_DECODERS = { pathClass: (field) => typeof field === "string", sourceVersion: isNullableCommandString, }), - capture_sccm_advanced_diagnostics: (value, commandName) => - decodeRecordResponse(value, commandName, { - bundleRoot: (field) => typeof field === "string", - capturedAtUtc: (field) => typeof field === "string", - roles: isStringArray, - sources: isCommandRecordArray, - artifactCount: isFiniteCommandNumber, - retainedBytes: isFiniteCommandNumber, - }), + capture_sccm_advanced_diagnostics: decodeSccmCaptureResult, cancel_sccm_advanced_capture: decodeUnitResponse, reveal_in_file_manager: decodeUnitResponse, get_update_policy: (value, commandName) => @@ -1675,22 +1816,8 @@ const COMMAND_DECODERS = { graph: isNullableCommandRecord, }), export_esp_session: decodeUnitResponse, - start_esp_diagnostics_session: (value, commandName) => - decodeRecordResponse(value, commandName, { - sessionId: (field) => typeof field === "string", - requestId: (field) => typeof field === "string", - sequence: isFiniteCommandNumber, - state: (field) => typeof field === "string", - snapshot: isCommandRecord, - }), - get_esp_diagnostics_session: (value, commandName) => - decodeRecordResponse(value, commandName, { - sessionId: (field) => typeof field === "string", - requestId: (field) => typeof field === "string", - sequence: isFiniteCommandNumber, - state: (field) => typeof field === "string", - snapshot: isCommandRecord, - }), + start_esp_diagnostics_session: decodeEspSessionEnvelope, + get_esp_diagnostics_session: decodeEspSessionEnvelope, stop_esp_diagnostics_session: decodeUnitResponse, restart_esp_as_administrator: (value, commandName) => decodeRecordResponse(value, commandName, { diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index 13bfd8f95..ba37200f5 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -572,15 +572,6 @@ export async function getKnownSourceMetadataById( return knownSources.find((source) => source.id === sourceId) ?? null; } -export function loadSelectedLogFile( - filePath: string, - source: LogSource, -): Promise; -export function loadSelectedLogFile( - filePath: string, - source: LogSource, - switchGeneration: number, -): Promise; export async function loadSelectedLogFile( filePath: string, source: LogSource, diff --git a/src/workspaces/event-log/open-event-log-source.test.ts b/src/workspaces/event-log/open-event-log-source.test.ts index a00de0ca1..b747f927b 100644 --- a/src/workspaces/event-log/open-event-log-source.test.ts +++ b/src/workspaces/event-log/open-event-log-source.test.ts @@ -19,6 +19,7 @@ describe("openEventLogSource", () => { vi.clearAllMocks(); useEvtxStore.setState({ parseFiles: vi.fn(async () => undefined), + setLoadError: vi.fn(), } as never); }); @@ -111,6 +112,9 @@ describe("openEventLogSource", () => { 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(); }); @@ -159,5 +163,8 @@ describe("openEventLogSource", () => { 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/intune/createIntuneOnOpenSource.test.ts b/src/workspaces/intune/createIntuneOnOpenSource.test.ts index b8d080578..974241054 100644 --- a/src/workspaces/intune/createIntuneOnOpenSource.test.ts +++ b/src/workspaces/intune/createIntuneOnOpenSource.test.ts @@ -95,4 +95,25 @@ describe("INTUNE-009 analyzeIntuneLogs Graph option", () => { { 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/timeline/index.ts b/src/workspaces/timeline/index.ts index ee94514a8..d5031afe1 100644 --- a/src/workspaces/timeline/index.ts +++ b/src/workspaces/timeline/index.ts @@ -28,8 +28,8 @@ export const timelineWorkspace: WorkspaceDefinition = { }, onOpenSource: async (source, trigger) => { useUiStore.getState().ensureWorkspaceVisible("timeline", trigger); - const { openTimelineSource } = await import("./open-timeline-source"); try { + const { openTimelineSource } = await import("./open-timeline-source"); await openTimelineSource(source); } catch (error) { console.error("[timeline] failed to open source", { diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index 96d8d7f22..6f648cefa 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; 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 { openTimelineSource, replaceTimelineSource, @@ -31,18 +32,35 @@ function deferred() { }; } -type TimelineBuildResult = Awaited>; - -function bundleFor(paths: string[]): TimelineBuildResult { +function bundleFor(paths: string[]): TimelineBundle { return { - sources: paths.map((path, idx) => ({ path, idx })), - } as TimelineBuildResult; + 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 } as never); + useTimelineStore.setState({ bundle: null, loadError: null }); }); it("builds a timeline from a single file", async () => { @@ -103,8 +121,8 @@ describe("openTimelineSource", () => { it("unions folder files with an existing timeline", async () => { useTimelineStore.setState({ - bundle: { sources: [{ path: "/tmp/existing.log" }] }, - } as never); + bundle: bundleFor(["/tmp/existing.log"]), + }); vi.mocked(listLogFolder).mockResolvedValue({ sourceKind: "folder", source: { kind: "folder", path: "/tmp/logs" }, @@ -134,10 +152,10 @@ describe("openTimelineSource", () => { }); it("replaces pending timeline appends instead of merging stale sources", async () => { useTimelineStore.setState({ - bundle: { sources: [{ path: "/tmp/existing.log" }] }, - } as never); - const firstBuild = deferred(); - const secondBuild = deferred(); + bundle: bundleFor(["/tmp/existing.log"]), + }); + const firstBuild = deferred(); + const secondBuild = deferred(); vi.mocked(buildTimelineFromSources) .mockImplementationOnce(async () => { const bundle = await firstBuild.promise; @@ -172,8 +190,8 @@ describe("openTimelineSource", () => { }); it("clears the current timeline for an empty replacement folder", async () => { useTimelineStore.setState({ - bundle: { sources: [{ path: "/tmp/existing.log" }] }, - } as never); + bundle: bundleFor(["/tmp/existing.log"]), + }); vi.mocked(listLogFolder).mockResolvedValue({ sourceKind: "folder", source: { kind: "folder", path: "/tmp/empty-replacement" }, @@ -191,8 +209,8 @@ describe("openTimelineSource", () => { it("clears the current timeline before a replacement load failure", async () => { useTimelineStore.setState({ - bundle: { sources: [{ path: "/tmp/existing.log" }] }, - } as never); + bundle: bundleFor(["/tmp/existing.log"]), + }); vi.mocked(listLogFolder).mockRejectedValue(new Error("access denied")); await expect( @@ -200,6 +218,7 @@ describe("openTimelineSource", () => { ).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 () => { @@ -234,8 +253,9 @@ describe("openTimelineSource", () => { it("does not treat an empty folder as an IntuneEvents source", async () => { useTimelineStore.setState({ - bundle: { sources: [{ path: "/tmp/existing.log" }] }, - } as never); + bundle: bundleFor(["/tmp/existing.log"]), + loadError: "stale error", + }); vi.mocked(listLogFolder).mockResolvedValue({ sourceKind: "folder", source: { kind: "folder", path: "/tmp/empty" }, @@ -244,11 +264,12 @@ describe("openTimelineSource", () => { 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 firstBuild = deferred(); + const secondBuild = deferred(); const folderSource = { kind: "folder" as const, path: "/tmp/logs" }; const fileSource = { kind: "file" as const, path: "/tmp/other.log" }; diff --git a/src/workspaces/timeline/open-timeline-source.ts b/src/workspaces/timeline/open-timeline-source.ts index 03cd3f768..7c3c46e82 100644 --- a/src/workspaces/timeline/open-timeline-source.ts +++ b/src/workspaces/timeline/open-timeline-source.ts @@ -26,7 +26,10 @@ function incomingFromListing( let timelineOpenQueue: Promise = Promise.resolve(); function enqueueTimelineOpen(operation: () => Promise): Promise { - const queued = timelineOpenQueue.then(operation); + const queued = timelineOpenQueue.then(() => { + useTimelineStore.getState().setLoadError(null); + return operation(); + }); timelineOpenQueue = queued.catch((error) => { useTimelineStore .getState() From 51f2aef6ee15d66322e2099a481a4024c211d652 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 04:19:44 -0400 Subject: [PATCH 22/30] fix: propagate event log parse failures --- src/workspaces/event-log/evtx-store.ts | 1 + .../event-log/open-event-log-source.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/workspaces/event-log/evtx-store.ts b/src/workspaces/event-log/evtx-store.ts index 6e83d5e5f..382b2e988 100644 --- a/src/workspaces/event-log/evtx-store.ts +++ b/src/workspaces/event-log/evtx-store.ts @@ -159,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; } }, diff --git a/src/workspaces/event-log/open-event-log-source.test.ts b/src/workspaces/event-log/open-event-log-source.test.ts index b747f927b..036cd671c 100644 --- a/src/workspaces/event-log/open-event-log-source.test.ts +++ b/src/workspaces/event-log/open-event-log-source.test.ts @@ -13,6 +13,7 @@ vi.mock("../../lib/commands", () => ({ 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(() => { @@ -29,6 +30,19 @@ describe("openEventLogSource", () => { "/tmp/Application.evtx", ]); }); + it("propagates file parse failures to the caller", async () => { + const parseFiles = actualParseFiles; + useEvtxStore.setState({ + parseFiles, + setLoadError: vi.fn(), + } as never); + 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"; From c9e7331b7da66217061d87e293cb2b250302f751 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 04:35:42 -0400 Subject: [PATCH 23/30] fix: close remaining review findings --- src/lib/commands.test.ts | 28 +++++++++++++++ src/lib/commands.ts | 4 +-- src/lib/log-source.test.ts | 35 +++++++++++++++++++ src/lib/log-source.ts | 1 + src/lib/session-restore.test.ts | 28 ++++++++------- src/stores/ui-store.test.ts | 1 + .../event-log/open-event-log-source.test.ts | 4 +-- 7 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 7347485d1..8f74b011d 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, @@ -132,6 +133,11 @@ describe("parse and folder IPC response validation", () => { 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); }); @@ -154,6 +160,28 @@ describe("parse and folder IPC response validation", () => { await expect(listLogFolder("C:\\Logs")).rejects.toThrow("invalid response"); }); }); +describe("Intune IPC response validation", () => { + it("accepts structured diagnostics metadata", async () => { + const result = { + events: [], + downloads: [], + summary: {}, + diagnostics: [], + sourceFile: "C:\\Logs\\IntuneManagementExtension.log", + sourceFiles: [], + diagnosticsCoverage: {}, + diagnosticsConfidence: {}, + repeatedFailures: [], + guidRegistry: {}, + }; + vi.mocked(invoke).mockResolvedValueOnce(result); + + await expect( + analyzeIntuneLogs("C:\\Logs", "request-1"), + ).resolves.toEqual(result); + }); +}); + function validTimelineBundle() { return { diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 44e69855d..79608a0ae 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1655,8 +1655,8 @@ const COMMAND_DECODERS = { diagnostics: isCommandRecordArray, sourceFile: (field) => typeof field === "string", sourceFiles: isStringArray, - diagnosticsCoverage: (field) => typeof field === "string", - diagnosticsConfidence: (field) => typeof field === "string", + diagnosticsCoverage: isCommandRecord, + diagnosticsConfidence: isCommandRecord, repeatedFailures: isCommandRecordArray, guidRegistry: isCommandRecord, }), diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index f33bea58c..4efbadafb 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -260,6 +260,41 @@ describe("switchToTab", () => { ); 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"); + let rejectRegistry!: (error: Error) => void; + const pendingRegistry = new Promise((_, reject) => { + rejectRegistry = reject; + }); + setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); + commands.openLogFile.mockResolvedValueOnce(registryResult); + commands.parseRegistryFile.mockReturnValueOnce(pendingRegistry); + + const pendingLoad = loadSelectedLogFile(fileA, fileSourceA); + await vi.waitFor(() => { + expect(commands.parseRegistryFile).toHaveBeenCalledWith(fileA); + }); + + await switchToTab(fileB, { + sourceKind: "file", + sourcePath: fileB, + source: fileSourceB, + }); + rejectRegistry(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 }; diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index ba37200f5..ea63aead4 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -176,6 +176,7 @@ async function applyParseResultToStore( try { registryData = await parseRegistryFile(selectedFilePath); } catch (err) { + if (!isCurrentTabSwitch(switchGeneration)) return false; console.error("[log-source] failed to load registry file", err); throw err; } diff --git a/src/lib/session-restore.test.ts b/src/lib/session-restore.test.ts index a52a27f5d..fed9299c7 100644 --- a/src/lib/session-restore.test.ts +++ b/src/lib/session-restore.test.ts @@ -19,21 +19,24 @@ const restoredLoadResult = { parseResult: null, }; -function sessionJson(clauses: unknown[]): string { +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: { @@ -73,10 +76,11 @@ describe("restoreSession filter restore (issue #193)", () => { }); it("does not aggregate after an individual restore is superseded", async () => { - vi.mocked(readTextFile).mockResolvedValue(sessionJson([])); + 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(); }); diff --git a/src/stores/ui-store.test.ts b/src/stores/ui-store.test.ts index 9f182b88f..25179a5f4 100644 --- a/src/stores/ui-store.test.ts +++ b/src/stores/ui-store.test.ts @@ -73,6 +73,7 @@ describe("ui-store", () => { }); it("filters invalid dismissed paths during rehydration", async () => { + useUiStore.setState({ dismissedDnsBannerPaths: [] }); localStorage.setItem( "cmtraceopen-ui-preferences", JSON.stringify({ diff --git a/src/workspaces/event-log/open-event-log-source.test.ts b/src/workspaces/event-log/open-event-log-source.test.ts index 036cd671c..9e973b91e 100644 --- a/src/workspaces/event-log/open-event-log-source.test.ts +++ b/src/workspaces/event-log/open-event-log-source.test.ts @@ -21,7 +21,7 @@ describe("openEventLogSource", () => { useEvtxStore.setState({ parseFiles: vi.fn(async () => undefined), setLoadError: vi.fn(), - } as never); + }); }); it("parses a single evtx file", async () => { @@ -35,7 +35,7 @@ describe("openEventLogSource", () => { useEvtxStore.setState({ parseFiles, setLoadError: vi.fn(), - } as never); + }); invoke.mockRejectedValueOnce(new Error("not a file")); await expect( From 6e584ef9355a401991816b4cf0cd4394e6b4d6da Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 05:09:48 -0400 Subject: [PATCH 24/30] test: close PR 577 review gaps --- .../log-view/LogRow.stories.test.tsx | 2 +- src/components/log-view/LogRow.tsx | 1 + src/hooks/use-context-menu.test.ts | 37 +++++++++- src/lib/commands.ts | 67 ++++++++++--------- src/lib/log-source.test.ts | 53 +++++++++++---- src/lib/log-source.ts | 13 ++-- .../deployment/DeploymentErrorCard.tsx | 4 +- .../deployment/DeploymentWorkspace.test.tsx | 4 +- .../timeline/open-timeline-source.test.ts | 30 ++++++++- 9 files changed, 155 insertions(+), 56 deletions(-) diff --git a/src/components/log-view/LogRow.stories.test.tsx b/src/components/log-view/LogRow.stories.test.tsx index a3ea79656..2da7cf9ac 100644 --- a/src/components/log-view/LogRow.stories.test.tsx +++ b/src/components/log-view/LogRow.stories.test.tsx @@ -91,7 +91,7 @@ describe("LogRow error codes and markers", () => { const { onClick, onToggleMarker, onSetMarkerCategory } = renderRow({ marker: { lineId: 4, category: "bug", color: "#ef4444", added: "2026-07-26T12:00:00Z" }, }); - const gutter = screen.getByRole("option").firstElementChild as HTMLElement; + 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(); diff --git a/src/components/log-view/LogRow.tsx b/src/components/log-view/LogRow.tsx index 69aa62432..e4c307217 100644 --- a/src/components/log-view/LogRow.tsx +++ b/src/components/log-view/LogRow.tsx @@ -351,6 +351,7 @@ export const LogRow = memo(function LogRow({ > {/* Marker gutter */}
vi.fn(async (opts: { id: string; text: string }) => opts)); +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[] }) => ({ @@ -16,8 +18,10 @@ vi.mock("@tauri-apps/api/menu", () => ({ Menu: { new: menuNew }, })); +const writeText = vi.hoisted(() => vi.fn(async (_text: string) => undefined)); + vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ - writeText: vi.fn(), + writeText, })); vi.mock("@tauri-apps/api/core", () => ({ @@ -26,6 +30,7 @@ vi.mock("@tauri-apps/api/core", () => ({ 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 { @@ -62,6 +67,8 @@ describe("useContextMenu", () => { menuItemNew.mockClear(); predefinedNew.mockClear(); menuNew.mockClear(); + writeText.mockClear(); + useFilterStore.getState().clearFilter(); useMarkerStore.setState({ markersByFile: new Map(), categories: [ @@ -96,4 +103,30 @@ describe("useContextMenu", () => { 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/lib/commands.ts b/src/lib/commands.ts index 79608a0ae..1decbb71f 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,7 +50,7 @@ import type { SccmAdvancedCaptureCapability, SccmEnvironmentDiscovery, } from "../workspaces/sccm/types"; -import type { TimelineBundle } from "../types/timeline"; +import type { SignalKind, TimelineBundle } from "../types/timeline"; export interface FileAssociationPromptStatus { supported: boolean; @@ -407,36 +408,40 @@ function isFolderListingResponse(value: unknown): value is FolderListingResult { isCommandRecord(value.bundleMetadata)) ); } -const TIMELINE_PARSER_KINDS = new Set([ - "ccm", - "simple", - "timestamped", - "plain", - "iisW3c", - "panther", - "cbs", - "dism", - "reportingEvents", - "msi", - "psadtLegacy", - "intuneMacOs", - "intuneDeviceInventory", - "dhcp", - "burn", - "patchMyPcDetection", - "registry", - "secureBootLog", - "dnsDebug", - "dnsAudit", - "cmtLog", - "companyPortal", -]); - -const TIMELINE_SIGNAL_KINDS = new Set([ - "errorSeverity", - "knownErrorCode", - "imeFailed", -]); +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; diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 4efbadafb..3aed91ff2 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -207,8 +207,10 @@ function evidenceBundleMetadata(): EvidenceBundleMetadata { function deferred() { let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { resolvePromise = resolve; + rejectPromise = reject; }); return { @@ -219,6 +221,12 @@ function deferred() { } resolvePromise(value); }, + reject(reason: unknown) { + if (!rejectPromise) { + throw new Error("Deferred promise rejecter was not initialized"); + } + rejectPromise(reason); + }, }; } @@ -273,13 +281,10 @@ describe("switchToTab", () => { }, }; const parseError = new Error("registry fixture is unreadable"); - let rejectRegistry!: (error: Error) => void; - const pendingRegistry = new Promise((_, reject) => { - rejectRegistry = reject; - }); + const pendingRegistry = deferred(); setCachedTabSnapshot(fileB, snapshotFor(fileB, "CIAgent line")); commands.openLogFile.mockResolvedValueOnce(registryResult); - commands.parseRegistryFile.mockReturnValueOnce(pendingRegistry); + commands.parseRegistryFile.mockReturnValueOnce(pendingRegistry.promise); const pendingLoad = loadSelectedLogFile(fileA, fileSourceA); await vi.waitFor(() => { @@ -291,7 +296,7 @@ describe("switchToTab", () => { sourcePath: fileB, source: fileSourceB, }); - rejectRegistry(parseError); + pendingRegistry.reject(parseError); await expect(pendingLoad).resolves.toBeNull(); }); @@ -651,6 +656,31 @@ describe("switchToTab", () => { 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 }; @@ -871,11 +901,8 @@ describe("source loading progress ownership", () => { it("returns null when selected-file recovery becomes stale", async () => { const selectedPath = sourceEntries[0].path; const currentPath = "C:/Windows/CCM/Logs/Current.log"; - let rejectOpenLogFile: (error: unknown) => void = () => undefined; - const pendingOpenLogFile = new Promise((_, reject) => { - rejectOpenLogFile = reject; - }); - commands.openLogFile.mockReturnValueOnce(pendingOpenLogFile); + const pendingOpenLogFile = deferred(); + commands.openLogFile.mockReturnValueOnce(pendingOpenLogFile.promise); const staleLoad = loadLogSource(folderSource, { selectedFilePath: selectedPath, }); @@ -888,7 +915,7 @@ describe("source loading progress ownership", () => { filePath: currentPath, }); await loadLogSource({ kind: "file", path: currentPath }); - rejectOpenLogFile(new Error("selected file failed")); + pendingOpenLogFile.reject(new Error("selected file failed")); await expect(staleLoad).resolves.toBeNull(); }); diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index ea63aead4..e7a97dcd5 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -677,7 +677,13 @@ export async function loadSelectedLogFile( await stopCurrentTailIfNeeded(filePath); if (!isCurrentTabSwitch(operationGeneration)) return null; - const result = await openLogFile(filePath); + let result: ParseResult; + try { + result = await openLogFile(filePath); + } catch (error) { + if (!isCurrentTabSwitch(operationGeneration)) return null; + throw error; + } if (!isCurrentTabSwitch(operationGeneration)) return null; const applied = await applyParseResultToStore( source, @@ -726,7 +732,6 @@ export async function switchToTab( if (sourceContext && sourceContext.sourceKind !== "file") { if ( !(await restoreFolderContext( - useLogStore.getState(), sourceContext, generation, )) @@ -792,7 +797,6 @@ export async function switchToTab( if (sourceContext && sourceContext.sourceKind !== "file") { try { const restored = await restoreFolderContext( - useLogStore.getState(), sourceContext, generation, ); @@ -832,7 +836,6 @@ export async function switchToTab( // Folder or known-source tab — restore sidebar then load the file if ( !(await restoreFolderContext( - useLogStore.getState(), sourceContext, generation, )) @@ -844,11 +847,11 @@ export async function switchToTab( /** Restore the sidebar folder listing if the active source changed. */ async function restoreFolderContext( - logState: ReturnType, sourceContext: TabSourceContext, restoreGeneration: number, ): Promise { if (!isCurrentTabSwitch(restoreGeneration)) return false; + const logState = useLogStore.getState(); const { source } = sourceContext; const currentSource = logState.activeSource; 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 index 5226706e6..3b6a6ac40 100644 --- a/src/workspaces/deployment/DeploymentWorkspace.test.tsx +++ b/src/workspaces/deployment/DeploymentWorkspace.test.tsx @@ -115,10 +115,10 @@ describe("DeploymentWorkspace fixtures", () => { 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 errors" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "1 error" })).toBeInTheDocument(); expect(screen.getByText("Installation failed with 1603")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "1 errors" })); + fireEvent.click(screen.getByRole("button", { name: "1 error" })); expect(screen.getByText(/L42/)).toBeInTheDocument(); expect(screen.getByText("CustomAction failed")).toBeInTheDocument(); diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index 6f648cefa..de9d4551f 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -4,10 +4,10 @@ import { buildTimelineFromSources } from "../../components/timeline/hooks/useTim 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(), })); @@ -119,6 +119,34 @@ describe("openTimelineSource", () => { ]); }); + 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"]), From 6737f2794af27bb968129dc4bc8996f1597a392a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 05:32:48 -0400 Subject: [PATCH 25/30] test: close remaining PR 577 review gaps --- src/components/dialogs/FilterDialog.test.tsx | 7 ++-- src/lib/commands.ts | 40 +++++++------------ src/workspaces/event-log/index.ts | 2 +- .../intune/IntuneDashboard.stories.test.tsx | 21 ++++++---- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/components/dialogs/FilterDialog.test.tsx b/src/components/dialogs/FilterDialog.test.tsx index 0aeeaa544..7c0b650ed 100644 --- a/src/components/dialogs/FilterDialog.test.tsx +++ b/src/components/dialogs/FilterDialog.test.tsx @@ -32,21 +32,22 @@ describe("FilterDialog", () => { ); const dialog = screen.getByRole("dialog", { name: "Filter" }); const input = dialog.querySelector("input"); - const firstFocusable = dialog.querySelector("select"); + 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(firstFocusable); + 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(firstFocusable); + expect(document.activeElement).toBe(tabWrapTarget); rendered.rerender( { - 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 ( @@ -1290,13 +1283,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 || @@ -1338,20 +1331,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; } @@ -1360,7 +1350,7 @@ function decodeGraphAuthStatus( value: unknown, commandName: string, ): GraphAuthStatus { - if (!isGraphAuthStatus(value)) return invalidGraphResponse(commandName); + if (!isGraphAuthStatus(value)) return invalidCommandResponse(commandName); return value; } @@ -1369,15 +1359,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; @@ -1388,15 +1378,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; } @@ -1406,13 +1396,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; } diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts index 94bc5a8a5..31625baa2 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -27,8 +27,8 @@ export const eventLogWorkspace: WorkspaceDefinition = { onOpenSource: async (source, trigger) => { useUiStore.getState().ensureWorkspaceVisible("event-log", trigger); // Lazy: evtx-store registers Tauri event listeners at module load. - const { openEventLogSource } = await import("./open-event-log-source"); try { + const { openEventLogSource } = await import("./open-event-log-source"); await openEventLogSource(source); } catch (error) { console.error("[event-log] failed to open source", { diff --git a/src/workspaces/intune/IntuneDashboard.stories.test.tsx b/src/workspaces/intune/IntuneDashboard.stories.test.tsx index 8b8fc4751..846afa69d 100644 --- a/src/workspaces/intune/IntuneDashboard.stories.test.tsx +++ b/src/workspaces/intune/IntuneDashboard.stories.test.tsx @@ -60,13 +60,20 @@ vi.mock("@tanstack/react-virtual", () => ({ } return total; }, - getVirtualItems: () => - Array.from({ length: count }, (_, index) => ({ - index, - key: getItemKey?.(index) ?? index, - size: estimateSize(index), - start: index * estimateSize(index), - })), + 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(), }), From 75b0ea33e43abeb4950813a2281548629d564af0 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 06:13:04 -0400 Subject: [PATCH 26/30] fix: close remaining PR 577 review gaps --- .../FileAssociationPromptDialog.test.tsx | 30 ++++++++++++ .../dialogs/FileAssociationPromptDialog.tsx | 7 ++- src/components/dialogs/FilterDialog.test.tsx | 48 +++++++++++++++++++ src/components/dialogs/FilterDialog.tsx | 2 +- .../log-view/LogListView.selection.test.tsx | 18 +++++-- src/components/log-view/LogRow.tsx | 2 +- src/hooks/use-app-actions.path-open.test.tsx | 7 +++ src/hooks/use-modal-focus.ts | 19 +++++--- src/lib/commands.test.ts | 39 ++++++++++----- src/lib/log-source.test.ts | 24 +--------- src/lib/log-source.ts | 40 +++++++++++----- src/test-utils/deferred.ts | 24 ++++++++++ src/workspaces/event-log/index.ts | 4 ++ .../timeline/open-timeline-source.test.ts | 16 +------ 14 files changed, 206 insertions(+), 74 deletions(-) create mode 100644 src/test-utils/deferred.ts diff --git a/src/components/dialogs/FileAssociationPromptDialog.test.tsx b/src/components/dialogs/FileAssociationPromptDialog.test.tsx index 182364143..9a7c9a9e4 100644 --- a/src/components/dialogs/FileAssociationPromptDialog.test.tsx +++ b/src/components/dialogs/FileAssociationPromptDialog.test.tsx @@ -48,4 +48,34 @@ describe("FileAssociationPromptDialog", () => { expect(document.activeElement).toBe(opener); opener.remove(); }); + + it("keeps focus on the dialog surface while submission disables its controls", () => { + vi.mocked(associateLogFilesWithApp).mockReturnValue( + new Promise(() => {}), + ); + + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + 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); + opener.remove(); + }); }); diff --git a/src/components/dialogs/FileAssociationPromptDialog.tsx b/src/components/dialogs/FileAssociationPromptDialog.tsx index bdf490087..672c106b0 100644 --- a/src/components/dialogs/FileAssociationPromptDialog.tsx +++ b/src/components/dialogs/FileAssociationPromptDialog.tsx @@ -26,7 +26,12 @@ export function FileAssociationPromptDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const dialogRef = useRef(null); - useModalFocus(isOpen, dialogRef); + useModalFocus( + isOpen, + dialogRef, + undefined, + isSubmitting ? "submitting" : "idle", + ); useEffect(() => { if (!isOpen) { diff --git a/src/components/dialogs/FilterDialog.test.tsx b/src/components/dialogs/FilterDialog.test.tsx index 7c0b650ed..8bae3421e 100644 --- a/src/components/dialogs/FilterDialog.test.tsx +++ b/src/components/dialogs/FilterDialog.test.tsx @@ -60,4 +60,52 @@ describe("FilterDialog", () => { 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(); + }); }); diff --git a/src/components/dialogs/FilterDialog.tsx b/src/components/dialogs/FilterDialog.tsx index 458eef925..ad6157f71 100644 --- a/src/components/dialogs/FilterDialog.tsx +++ b/src/components/dialogs/FilterDialog.tsx @@ -60,7 +60,7 @@ export function FilterDialog({ const isFiltering = useFilterStore((s) => s.isFiltering); const filterError = useFilterStore((s) => s.filterError); - useModalFocus(isOpen, dialogRef, inputRef); + useModalFocus(isOpen, dialogRef, inputRef, clauses.length); useEffect(() => { if (isOpen) { diff --git a/src/components/log-view/LogListView.selection.test.tsx b/src/components/log-view/LogListView.selection.test.tsx index ead1f41be..96537113b 100644 --- a/src/components/log-view/LogListView.selection.test.tsx +++ b/src/components/log-view/LogListView.selection.test.tsx @@ -97,7 +97,7 @@ describe("LogListView selection and jump fixtures", () => { ); }); - it("toggles additive selection with Ctrl/Cmd+click and ranges with Shift+click", () => { + 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 }); @@ -109,12 +109,24 @@ describe("LogListView selection and jump fixtures", () => { 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 4 completed").closest("[role='option']")).toHaveStyle({ + + 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("selects every displayed row on Ctrl/Cmd+A", () => { render(); const list = screen.getByRole("listbox", { name: "Log entries" }); diff --git a/src/components/log-view/LogRow.tsx b/src/components/log-view/LogRow.tsx index e4c307217..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 (
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, @@ -25,6 +26,11 @@ vi.mock("../lib/commands", () => ({ 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"; @@ -80,5 +86,6 @@ describe("openPathForActiveWorkspace event-log", () => { kind: "folder", path: "C:/Windows/System32/winevt/Logs", }); + expect(setLoadError).toHaveBeenCalledWith("not a file"); }); }); diff --git a/src/hooks/use-modal-focus.ts b/src/hooks/use-modal-focus.ts index 0863390a2..1519979a8 100644 --- a/src/hooks/use-modal-focus.ts +++ b/src/hooks/use-modal-focus.ts @@ -9,6 +9,12 @@ const FOCUSABLE_SELECTOR = [ '[tabindex]:not([tabindex="-1"])', ].join(", "); +function getFocusableControls(surface: HTMLElement): HTMLElement[] { + return Array.from( + surface.querySelectorAll(FOCUSABLE_SELECTOR), + ); +} + export function useModalFocus( isOpen: boolean, surfaceRef: RefObject, @@ -41,20 +47,20 @@ export function useModalFocus( const surface = surfaceRef.current; if (!surface) return; + const focusable = getFocusableControls(surface); const active = document.activeElement instanceof HTMLElement ? document.activeElement : null; - if (active && surface.contains(active)) return; + if (active && focusable.includes(active)) return; const preferred = initialFocusRef?.current; const target = preferred && !preferred.hasAttribute("disabled") ? preferred - : surface.querySelector(FOCUSABLE_SELECTOR) ?? surface; + : focusable[0] ?? surface; target.focus(); }, [focusKey, initialFocusRef, isOpen, surfaceRef]); - useEffect(() => { if (!isOpen) return; @@ -64,9 +70,7 @@ export function useModalFocus( const surface = surfaceRef.current; if (!surface) return; - const focusable = Array.from( - surface.querySelectorAll(FOCUSABLE_SELECTOR), - ); + const focusable = getFocusableControls(surface); if (focusable.length === 0) { event.preventDefault(); surface.focus(); @@ -80,7 +84,7 @@ export function useModalFocus( ? document.activeElement : null; - if (!active || !surface.contains(active)) { + if (!active || !focusable.includes(active)) { event.preventDefault(); (event.shiftKey ? last : first).focus(); return; @@ -99,4 +103,5 @@ export function useModalFocus( window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen, surfaceRef]); + } diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 8f74b011d..6850a6511 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -160,25 +160,42 @@ describe("parse and folder IPC response validation", () => { await expect(listLogFolder("C:\\Logs")).rejects.toThrow("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 = { - events: [], - downloads: [], - summary: {}, - diagnostics: [], - sourceFile: "C:\\Logs\\IntuneManagementExtension.log", - sourceFiles: [], - diagnosticsCoverage: {}, - diagnosticsConfidence: {}, - repeatedFailures: [], - guidRegistry: {}, + ...validIntuneAnalysis(), + diagnosticsCoverage: "complete", + diagnosticsConfidence: "high", }; vi.mocked(invoke).mockResolvedValueOnce(result); await expect( analyzeIntuneLogs("C:\\Logs", "request-1"), - ).resolves.toEqual(result); + ).rejects.toThrow("invalid response"); }); }); diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 3aed91ff2..2cad558be 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deferred } from "../test-utils/deferred"; import type { FolderEntry, FolderListingResult, @@ -205,30 +206,7 @@ function evidenceBundleMetadata(): EvidenceBundleMetadata { } -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); - }, - }; -} describe("switchToTab", () => { const fileA = "C:/Windows/CCM/Logs/AppEnforce.log"; diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index e7a97dcd5..0fda97274 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -448,6 +448,9 @@ async function recoverFromSelectedFileLoadFailure( error, }); + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) { return null; @@ -578,9 +581,8 @@ export async function loadSelectedLogFile( source: LogSource, switchGeneration?: number, ): Promise { - const operationGeneration = - switchGeneration ?? ++tabSwitchGeneration; - if (!isCurrentTabSwitch(operationGeneration)) return null; + 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 @@ -592,15 +594,15 @@ export async function loadSelectedLogFile( console.info("[log-source] loadSelectedLogFile registry from cache", { filePath }); const { getCachedRegistry, setCachedRegistry, useRegistryStore } = await import("../stores/registry-store"); - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; let regData = getCachedRegistry(filePath); if (!regData) { regData = await parseRegistryFile(filePath); - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; setCachedRegistry(filePath, regData); } - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; state.setSelectedSourceFilePath(filePath); state.setSourceOpenMode("single-file"); @@ -666,7 +668,7 @@ export async function loadSelectedLogFile( filePath, }); - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; state.setLoading(true); state.setSourceStatus({ kind: "loading", @@ -674,26 +676,27 @@ export async function loadSelectedLogFile( }); try { + if (!isCurrentTabSwitch(loadGeneration)) return null; await stopCurrentTailIfNeeded(filePath); - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; let result: ParseResult; try { result = await openLogFile(filePath); } catch (error) { - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; throw error; } - if (!isCurrentTabSwitch(operationGeneration)) return null; + if (!isCurrentTabSwitch(loadGeneration)) return null; const applied = await applyParseResultToStore( source, result.filePath, result, - operationGeneration, + loadGeneration, ); return applied ? result : null; } finally { - if (isCurrentTabSwitch(operationGeneration)) { + if (isCurrentTabSwitch(loadGeneration)) { state.setLoading(false); } } @@ -896,6 +899,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { state.setFolderLoadRequestId(loadGeneration); // Clean up current state before starting the parse + if (!isCurrentTabSwitch(loadGeneration)) return; await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) return; useFilterStore.getState().clearFilter(); @@ -1143,6 +1147,9 @@ export async function loadLogSource( try { if (source.kind === "file") { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(source.path); if (!isCurrentTabSwitch(loadGeneration)) { return null; @@ -1185,6 +1192,9 @@ export async function loadLogSource( state.setBundleMetadata(listing.bundleMetadata ?? null); if (!requestedFilePath) { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) { return null; @@ -1225,6 +1235,9 @@ export async function loadLogSource( } if (source.pathKind === "file") { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(source.defaultPath); if (!isCurrentTabSwitch(loadGeneration)) { return null; @@ -1264,6 +1277,9 @@ export async function loadLogSource( state.setBundleMetadata(listing.bundleMetadata ?? null); if (!requestedFilePath) { + if (!isCurrentTabSwitch(loadGeneration)) { + return null; + } await stopCurrentTailIfNeeded(null); if (!isCurrentTabSwitch(loadGeneration)) { return null; 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/event-log/index.ts b/src/workspaces/event-log/index.ts index 31625baa2..ffada4ffe 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -31,6 +31,10 @@ export const eventLogWorkspace: WorkspaceDefinition = { const { openEventLogSource } = await import("./open-event-log-source"); await openEventLogSource(source); } catch (error) { + const { useEvtxStore } = await import("./evtx-store"); + useEvtxStore.getState().setLoadError( + error instanceof Error ? error.message : String(error), + ); console.error("[event-log] failed to open source", { source, trigger, diff --git a/src/workspaces/timeline/open-timeline-source.test.ts b/src/workspaces/timeline/open-timeline-source.test.ts index de9d4551f..73b1748e2 100644 --- a/src/workspaces/timeline/open-timeline-source.test.ts +++ b/src/workspaces/timeline/open-timeline-source.test.ts @@ -1,4 +1,5 @@ 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"; @@ -15,22 +16,7 @@ vi.mock("../../lib/commands", () => ({ vi.mock("../../components/timeline/hooks/useTimelineBundle", () => ({ buildTimelineFromSources: vi.fn(async () => ({ sources: [] })), })); -function deferred() { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { - promise, - resolve(value: T) { - if (!resolvePromise) { - throw new Error("Deferred promise resolver was not initialized"); - } - resolvePromise(value); - }, - }; -} function bundleFor(paths: string[]): TimelineBundle { return { From 329fdcc2bdae536facede68a2a269e36d9ed3c8e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 06:38:33 -0400 Subject: [PATCH 27/30] fix: close remaining PR 577 review gaps --- .../FileAssociationPromptDialog.test.tsx | 21 +++++++------ src/components/dialogs/FilterDialog.test.tsx | 30 +++++++++++++++++-- src/components/dialogs/FilterDialog.tsx | 7 ++++- src/lib/commands.test.ts | 8 +++-- src/workspaces/event-log/index.ts | 12 +++++--- 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/components/dialogs/FileAssociationPromptDialog.test.tsx b/src/components/dialogs/FileAssociationPromptDialog.test.tsx index 9a7c9a9e4..bdb9d3644 100644 --- a/src/components/dialogs/FileAssociationPromptDialog.test.tsx +++ b/src/components/dialogs/FileAssociationPromptDialog.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { associateLogFilesWithApp, setFileAssociationPromptSuppressed, @@ -12,13 +12,21 @@ vi.mock("../../lib/commands", () => ({ })); describe("FileAssociationPromptDialog", () => { + let opener: HTMLButtonElement; + + beforeEach(() => { + 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 opener = document.createElement("button"); - document.body.appendChild(opener); - opener.focus(); const rendered = render( {}} />, @@ -46,7 +54,6 @@ describe("FileAssociationPromptDialog", () => { {}} />, ); expect(document.activeElement).toBe(opener); - opener.remove(); }); it("keeps focus on the dialog surface while submission disables its controls", () => { @@ -54,9 +61,6 @@ describe("FileAssociationPromptDialog", () => { new Promise(() => {}), ); - const opener = document.createElement("button"); - document.body.appendChild(opener); - opener.focus(); const rendered = render( {}} />, @@ -76,6 +80,5 @@ describe("FileAssociationPromptDialog", () => { {}} />, ); expect(document.activeElement).toBe(opener); - opener.remove(); }); }); diff --git a/src/components/dialogs/FilterDialog.test.tsx b/src/components/dialogs/FilterDialog.test.tsx index 8bae3421e..f479aa339 100644 --- a/src/components/dialogs/FilterDialog.test.tsx +++ b/src/components/dialogs/FilterDialog.test.tsx @@ -1,7 +1,12 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { useFilterStore } from "../../stores/filter-store"; import { FilterDialog } from "./FilterDialog"; + afterEach(() => { + useFilterStore.getState().clearFilter(); + }); + describe("FilterDialog", () => { it("exposes a dialog landmark when open", () => { render( @@ -108,4 +113,25 @@ describe("FilterDialog", () => { 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 ad6157f71..a6d330fb3 100644 --- a/src/components/dialogs/FilterDialog.tsx +++ b/src/components/dialogs/FilterDialog.tsx @@ -60,7 +60,12 @@ export function FilterDialog({ const isFiltering = useFilterStore((s) => s.isFiltering); const filterError = useFilterStore((s) => s.filterError); - useModalFocus(isOpen, dialogRef, inputRef, clauses.length); + useModalFocus( + isOpen, + dialogRef, + inputRef, + `${clauses.length}:${isFiltering ? "filtering" : "ready"}`, + ); useEffect(() => { if (isOpen) { diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 6850a6511..3a1650db3 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -152,12 +152,14 @@ describe("parse and folder IPC response validation", () => { }); await expect(openLogFile("C:\\Logs\\App.log")).rejects.toThrow( - "invalid response", + "Command 'open_log_file' returned an invalid response.", ); await expect(parseFilesBatch(["C:\\Logs\\App.log"], 7, 0)).rejects.toThrow( - "invalid response", + "Command 'parse_files_batch' returned an invalid response.", + ); + await expect(listLogFolder("C:\\Logs")).rejects.toThrow( + "Command 'list_log_folder' returned an invalid response.", ); - await expect(listLogFolder("C:\\Logs")).rejects.toThrow("invalid response"); }); }); function validIntuneAnalysis() { diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts index ffada4ffe..530581c77 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -31,15 +31,19 @@ export const eventLogWorkspace: WorkspaceDefinition = { const { openEventLogSource } = await import("./open-event-log-source"); await openEventLogSource(source); } catch (error) { - const { useEvtxStore } = await import("./evtx-store"); - useEvtxStore.getState().setLoadError( - error instanceof Error ? error.message : String(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; } From b427fa939ca173fe14c8f5fd6ef3f29c84c266a0 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 06:54:56 -0400 Subject: [PATCH 28/30] test: close remaining PR 577 review gaps --- .../deployment/DeploymentWorkspace.test.tsx | 32 +++++++++++++++++-- .../event-log/evtx-store-coverage.test.ts | 10 ++++++ src/workspaces/event-log/evtx-store.ts | 7 +++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/workspaces/deployment/DeploymentWorkspace.test.tsx b/src/workspaces/deployment/DeploymentWorkspace.test.tsx index 3b6a6ac40..7176fbb04 100644 --- a/src/workspaces/deployment/DeploymentWorkspace.test.tsx +++ b/src/workspaces/deployment/DeploymentWorkspace.test.tsx @@ -1,5 +1,8 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +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, @@ -7,6 +10,10 @@ import { 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", @@ -84,6 +91,10 @@ function seedReady() { afterEach(() => { cleanup(); useDeploymentStore.getState().reset(); + useLogStore.getState().setPendingScrollTarget(null); + useUiStore + .getState() + .setActiveView(useUiStore.getInitialState().activeView); }); beforeEach(() => { @@ -128,4 +139,21 @@ describe("DeploymentWorkspace fixtures", () => { 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/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 382b2e988..19f202fc7 100644 --- a/src/workspaces/event-log/evtx-store.ts +++ b/src/workspaces/event-log/evtx-store.ts @@ -450,7 +450,12 @@ export const useEvtxStore = create()((set, get) => ({ loadElapsedMs: performance.now() - startTime, }); }, - setLoadError: (error) => set({ isLoading: false, loadError: error }), + setLoadError: (error) => + set( + error === null + ? { loadError: null } + : { isLoading: false, loadError: error }, + ), setTimeZoneMode: (mode) => set({ timeZoneMode: mode }), From de71d85f3672ed45a91d78fdfa662499414f8805 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 07:20:30 -0400 Subject: [PATCH 29/30] fix: close remaining PR 577 review gaps --- .../FileAssociationPromptDialog.test.tsx | 1 + .../timeline/hooks/useTimelineBundle.test.ts | 51 +++++++++++++ .../timeline/hooks/useTimelineBundle.ts | 5 +- src/hooks/use-drag-drop.test.tsx | 2 +- src/hooks/use-file-association.test.tsx | 2 +- src/lib/commands.test.ts | 65 ++++++++++++++++- src/lib/commands.ts | 71 ++++++++++++++++++- src/lib/log-source.test.ts | 22 ++++++ src/lib/log-source.ts | 19 ++--- src/lib/session-restore.test.ts | 16 ++++- src/lib/session-restore.ts | 6 +- src/stores/timeline-store.ts | 7 +- 12 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 src/components/timeline/hooks/useTimelineBundle.test.ts diff --git a/src/components/dialogs/FileAssociationPromptDialog.test.tsx b/src/components/dialogs/FileAssociationPromptDialog.test.tsx index bdb9d3644..7447fd0cb 100644 --- a/src/components/dialogs/FileAssociationPromptDialog.test.tsx +++ b/src/components/dialogs/FileAssociationPromptDialog.test.tsx @@ -15,6 +15,7 @@ describe("FileAssociationPromptDialog", () => { let opener: HTMLButtonElement; beforeEach(() => { + vi.clearAllMocks(); opener = document.createElement("button"); document.body.appendChild(opener); opener.focus(); 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 0a5772147..958e8257a 100644 --- a/src/components/timeline/hooks/useTimelineBundle.ts +++ b/src/components/timeline/hooks/useTimelineBundle.ts @@ -6,8 +6,11 @@ import type { TimelineBundle } from "../../../types/timeline"; export async function buildTimelineFromSources( sources: { path: string; displayName?: string }[], ): Promise { + const timelineGeneration = useTimelineStore.getState().timelineGeneration; const bundle = await buildTimeline(sources); - useTimelineStore.getState().setBundle(bundle); + if (useTimelineStore.getState().timelineGeneration === timelineGeneration) { + useTimelineStore.getState().setBundle(bundle); + } return bundle; } diff --git a/src/hooks/use-drag-drop.test.tsx b/src/hooks/use-drag-drop.test.tsx index 563b7ca0b..0bc6c08f3 100644 --- a/src/hooks/use-drag-drop.test.tsx +++ b/src/hooks/use-drag-drop.test.tsx @@ -49,7 +49,7 @@ describe("useDragDrop", () => { vi.clearAllMocks(); onDragDropEventMock.mockResolvedValue(() => undefined); openPathForActiveWorkspaceMock.mockResolvedValue(undefined); - loadFilesAsLogSourceMock.mockResolvedValue(undefined); + loadFilesAsLogSourceMock.mockResolvedValue(true); useUiStore.setState({ activeWorkspace: "log", activeView: "log", diff --git a/src/hooks/use-file-association.test.tsx b/src/hooks/use-file-association.test.tsx index 2a6718425..9327cdf7f 100644 --- a/src/hooks/use-file-association.test.tsx +++ b/src/hooks/use-file-association.test.tsx @@ -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 () => { diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 3a1650db3..028999d5a 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -18,6 +18,7 @@ import { openLogFile, parseFilesBatch, revealInFileManager, + inspectEvidenceArtifact, } from "./commands"; import { readAccessDenied } from "./source-error"; @@ -214,9 +215,17 @@ function validTimelineBundle() { 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: 1, + totalEntries: 2, incidents: [ { id: 0, @@ -273,6 +282,60 @@ describe("timeline IPC response validation", () => { }); }); +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, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 1a36e6374..93feab461 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -685,6 +685,73 @@ function isCommandRecordArray(value: unknown): boolean { 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, @@ -1615,8 +1682,10 @@ const COMMAND_DECODERS = { inspect_evidence_artifact: (value, commandName) => decodeRecordResponse(value, commandName, { path: (field) => typeof field === "string", - intakeKind: (field) => typeof field === "string", + intakeKind: isEvidenceArtifactIntakeKind, summary: (field) => typeof field === "string", + registrySnapshot: isNullableRegistrySnapshotSummary, + eventLogExport: isNullableEvidenceEventLogExportPreview, }), parse_registry_file: (value, commandName) => decodeRecordResponse(value, commandName, { diff --git a/src/lib/log-source.test.ts b/src/lib/log-source.test.ts index 2cad558be..e41ac15ba 100644 --- a/src/lib/log-source.test.ts +++ b/src/lib/log-source.test.ts @@ -992,4 +992,26 @@ describe("source loading progress ownership", () => { 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 0fda97274..2abc44757 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -884,13 +884,15 @@ async function restoreFolderContext( * 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; @@ -899,9 +901,9 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { state.setFolderLoadRequestId(loadGeneration); // Clean up current state before starting the parse - if (!isCurrentTabSwitch(loadGeneration)) return; + if (!isCurrentTabSwitch(loadGeneration)) return false; await stopCurrentTailIfNeeded(null); - if (!isCurrentTabSwitch(loadGeneration)) return; + if (!isCurrentTabSwitch(loadGeneration)) return false; useFilterStore.getState().clearFilter(); state.setLoading(true); @@ -916,7 +918,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { try { const results = await parseFilesBatch(paths, loadGeneration, 0); - if (!isCurrentTabSwitch(loadGeneration)) return; + if (!isCurrentTabSwitch(loadGeneration)) return false; const parseMs = Math.round(performance.now() - startTime); // Cache each file for instant tab switching @@ -975,7 +977,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { modifiedUnixMs: 0, })); - if (!isCurrentTabSwitch(loadGeneration)) return; + if (!isCurrentTabSwitch(loadGeneration)) return false; state.setActiveSource(source); state.setSourceEntries(folderEntries); state.setSelectedSourceFilePath(null); @@ -1002,6 +1004,7 @@ export async function loadFilesAsLogSource(paths: string[]): Promise { message: `Loaded ${aggregateFiles.length} files.`, detail: `Parsed in ${parseMs} ms (parallel).`, }); + return true; } finally { if (isCurrentTabSwitch(loadGeneration)) { state.setLoading(false); diff --git a/src/lib/session-restore.test.ts b/src/lib/session-restore.test.ts index fed9299c7..1e0633b90 100644 --- a/src/lib/session-restore.test.ts +++ b/src/lib/session-restore.test.ts @@ -9,7 +9,7 @@ import { useFilterStore } from "../stores/filter-store"; // filter clauses end up in the filter store (issue #193). vi.mock("./log-source", () => ({ loadPathAsLogSource: vi.fn().mockResolvedValue({}), - loadFilesAsLogSource: vi.fn().mockResolvedValue(undefined), + loadFilesAsLogSource: vi.fn().mockResolvedValue(true), })); const restoredLoadResult = { @@ -56,7 +56,7 @@ describe("restoreSession filter restore (issue #193)", () => { vi.mocked(loadPathAsLogSource) .mockReset() .mockResolvedValue(restoredLoadResult); - vi.mocked(loadFilesAsLogSource).mockReset().mockResolvedValue(undefined); + 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(); @@ -83,6 +83,18 @@ describe("restoreSession filter restore (issue #193)", () => { 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 ea74b85a0..14b324147 100644 --- a/src/lib/session-restore.ts +++ b/src/lib/session-restore.ts @@ -129,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/timeline-store.ts b/src/stores/timeline-store.ts index 9ad58f111..de7cc2f51 100644 --- a/src/stores/timeline-store.ts +++ b/src/stores/timeline-store.ts @@ -8,6 +8,7 @@ import type { interface TimelineState { bundle: TimelineBundle | null; + timelineGeneration: number; loadError: string | null; selectedIncidentId: number | null; brushRange: [number, number] | null; @@ -37,6 +38,7 @@ const MAX_ENTRY_CACHE = 128; export const useTimelineStore = create((set, get) => ({ bundle: null, + timelineGeneration: 0, loadError: null, selectedIncidentId: null, brushRange: null, @@ -50,16 +52,17 @@ 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 }); From 65a35046049e362b7b7046fc2c752afb4e3cbb92 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 19 Aug 2026 07:37:52 -0400 Subject: [PATCH 30/30] fix: cover keyboard and selection review gaps --- .../log-view/LogListView.selection.test.tsx | 24 ++++++++++--------- src/hooks/use-app-menu.test.tsx | 17 +++++++++++++ src/hooks/use-keyboard.ts | 3 ++- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/components/log-view/LogListView.selection.test.tsx b/src/components/log-view/LogListView.selection.test.tsx index 96537113b..fb10e994a 100644 --- a/src/components/log-view/LogListView.selection.test.tsx +++ b/src/components/log-view/LogListView.selection.test.tsx @@ -127,17 +127,19 @@ describe("LogListView selection and jump fixtures", () => { outline: "1px solid rgba(59, 130, 246, 0.5)", }); }); - it("selects every displayed row on Ctrl/Cmd+A", () => { - render(); - const list = screen.getByRole("listbox", { name: "Log entries" }); - fireEvent.keyDown(list, { key: "a", metaKey: true }); - fireEvent.keyDown(list, { key: "a", ctrlKey: true }); - 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.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(); diff --git a/src/hooks/use-app-menu.test.tsx b/src/hooks/use-app-menu.test.tsx index 8e58a2167..24677c8b1 100644 --- a/src/hooks/use-app-menu.test.tsx +++ b/src/hooks/use-app-menu.test.tsx @@ -602,6 +602,23 @@ describe("useKeyboard native menu parity", () => { ).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-keyboard.ts b/src/hooks/use-keyboard.ts index 102db1eb8..a8e63cd90 100644 --- a/src/hooks/use-keyboard.ts +++ b/src/hooks/use-keyboard.ts @@ -176,7 +176,8 @@ export function useKeyboard() { useEffect(() => { const handleKeyDown = async (event: KeyboardEvent) => { const suppressibleShortcut = - event.ctrlKey || event.metaKey || /^F\d{1,2}$/.test(event.key); + ((event.ctrlKey || event.metaKey) && !event.altKey) || + /^F\d{1,2}$/.test(event.key); const isInput = isTypingTarget(event.target); // Modal surfaces own Escape/Tab handling, but global app and browser