jettypod 4.4.118 → 4.4.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
//! WebSocket server for live dashboard updates.
|
|
2
|
+
//!
|
|
3
|
+
//! Broadcasts `db_change` and `test_change` events when the work database
|
|
4
|
+
//! or test results are modified. Uses native file watching (kqueue/FSEvents
|
|
5
|
+
//! on macOS, inotify on Linux) for instant change detection, with mtime
|
|
6
|
+
//! polling as a fallback.
|
|
7
|
+
//!
|
|
8
|
+
//! ## Usage
|
|
9
|
+
//!
|
|
10
|
+
//! ```rust,no_run
|
|
11
|
+
//! use jettypod_core::ws::{WsServer, WsConfig};
|
|
12
|
+
//! use std::path::PathBuf;
|
|
13
|
+
//! use tokio::sync::broadcast;
|
|
14
|
+
//!
|
|
15
|
+
//! #[tokio::main]
|
|
16
|
+
//! async fn main() {
|
|
17
|
+
//! let config = WsConfig {
|
|
18
|
+
//! port: 47808,
|
|
19
|
+
//! project_root: PathBuf::from("/path/to/project"),
|
|
20
|
+
//! };
|
|
21
|
+
//! let (tx, _) = broadcast::channel::<String>(64);
|
|
22
|
+
//! let server = WsServer::new(config);
|
|
23
|
+
//! server.run(tx).await.unwrap();
|
|
24
|
+
//! }
|
|
25
|
+
//! ```
|
|
26
|
+
|
|
27
|
+
use std::path::{Path, PathBuf};
|
|
28
|
+
use std::sync::Arc;
|
|
29
|
+
use std::time::{Duration, Instant, SystemTime};
|
|
30
|
+
|
|
31
|
+
use futures_util::{SinkExt, StreamExt};
|
|
32
|
+
use serde::Serialize;
|
|
33
|
+
use tokio::net::{TcpListener, TcpStream};
|
|
34
|
+
use tokio::sync::{broadcast, RwLock};
|
|
35
|
+
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
|
36
|
+
|
|
37
|
+
use notify::Watcher;
|
|
38
|
+
|
|
39
|
+
use crate::error::{CoreError, Result};
|
|
40
|
+
|
|
41
|
+
/// Default WebSocket port (matches Node.js `WS_PORT`).
|
|
42
|
+
pub const DEFAULT_PORT: u16 = 47808;
|
|
43
|
+
|
|
44
|
+
/// Debounce window for native file watch events (milliseconds).
|
|
45
|
+
const DEBOUNCE_MS: u64 = 100;
|
|
46
|
+
|
|
47
|
+
/// Polling interval for fallback mtime checks (milliseconds).
|
|
48
|
+
const POLL_MS: u64 = 500;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Message types
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/// A broadcast message sent to all connected clients.
|
|
55
|
+
#[derive(Debug, Clone, Serialize)]
|
|
56
|
+
pub struct BroadcastMessage {
|
|
57
|
+
#[serde(rename = "type")]
|
|
58
|
+
pub msg_type: String,
|
|
59
|
+
pub timestamp: u64,
|
|
60
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
61
|
+
pub table: Option<String>,
|
|
62
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
63
|
+
pub rowid: Option<i64>,
|
|
64
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
65
|
+
pub action: Option<String>,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
impl BroadcastMessage {
|
|
69
|
+
pub fn db_change() -> Self {
|
|
70
|
+
Self {
|
|
71
|
+
msg_type: "db_change".into(),
|
|
72
|
+
timestamp: now_millis(),
|
|
73
|
+
table: None,
|
|
74
|
+
rowid: None,
|
|
75
|
+
action: None,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
pub fn db_delta(action: &str, table: &str, rowid: i64) -> Self {
|
|
80
|
+
Self {
|
|
81
|
+
msg_type: "db_delta".into(),
|
|
82
|
+
timestamp: now_millis(),
|
|
83
|
+
table: Some(table.to_string()),
|
|
84
|
+
rowid: Some(rowid),
|
|
85
|
+
action: Some(action.to_string()),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pub fn test_change() -> Self {
|
|
90
|
+
Self {
|
|
91
|
+
msg_type: "test_change".into(),
|
|
92
|
+
timestamp: now_millis(),
|
|
93
|
+
table: None,
|
|
94
|
+
rowid: None,
|
|
95
|
+
action: None,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pub fn connected() -> Self {
|
|
100
|
+
Self {
|
|
101
|
+
msg_type: "connected".into(),
|
|
102
|
+
timestamp: now_millis(),
|
|
103
|
+
table: None,
|
|
104
|
+
rowid: None,
|
|
105
|
+
action: None,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pub fn to_json(&self) -> String {
|
|
110
|
+
serde_json::to_string(self).unwrap_or_default()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn now_millis() -> u64 {
|
|
115
|
+
SystemTime::now()
|
|
116
|
+
.duration_since(SystemTime::UNIX_EPOCH)
|
|
117
|
+
.unwrap_or_default()
|
|
118
|
+
.as_millis() as u64
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Native file watcher
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/// Set up native file watching for database and test result changes.
|
|
126
|
+
///
|
|
127
|
+
/// Watches the parent directories of the target files and filters events
|
|
128
|
+
/// by filename. Spawns a dedicated thread for the blocking event loop.
|
|
129
|
+
fn setup_file_watcher(
|
|
130
|
+
db_path: &Path,
|
|
131
|
+
test_path: &Path,
|
|
132
|
+
tx: &broadcast::Sender<String>,
|
|
133
|
+
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
|
134
|
+
let (notify_tx, notify_rx) = std::sync::mpsc::channel();
|
|
135
|
+
let mut watcher = notify::recommended_watcher(notify_tx)?;
|
|
136
|
+
|
|
137
|
+
let db_dir = db_path.parent().ok_or("db_path has no parent directory")?;
|
|
138
|
+
watcher.watch(db_dir, notify::RecursiveMode::NonRecursive)?;
|
|
139
|
+
|
|
140
|
+
let test_dir = test_path.parent().ok_or("test_path has no parent directory")?;
|
|
141
|
+
if test_dir != db_dir {
|
|
142
|
+
watcher.watch(test_dir, notify::RecursiveMode::NonRecursive)?;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let db_name = db_path
|
|
146
|
+
.file_name()
|
|
147
|
+
.ok_or("db_path has no filename")?
|
|
148
|
+
.to_owned();
|
|
149
|
+
let wal_path = db_path.with_extension("db-wal");
|
|
150
|
+
let wal_name = wal_path
|
|
151
|
+
.file_name()
|
|
152
|
+
.ok_or("wal path has no filename")?
|
|
153
|
+
.to_owned();
|
|
154
|
+
let test_name = test_path
|
|
155
|
+
.file_name()
|
|
156
|
+
.ok_or("test_path has no filename")?
|
|
157
|
+
.to_owned();
|
|
158
|
+
let tx = tx.clone();
|
|
159
|
+
|
|
160
|
+
std::thread::Builder::new()
|
|
161
|
+
.name("jettypod-fswatcher".into())
|
|
162
|
+
.spawn(move || {
|
|
163
|
+
let _watcher = watcher; // Must stay alive for watching to continue
|
|
164
|
+
let debounce = Duration::from_millis(DEBOUNCE_MS);
|
|
165
|
+
let mut last_db = Instant::now() - Duration::from_secs(60);
|
|
166
|
+
let mut last_test = Instant::now() - Duration::from_secs(60);
|
|
167
|
+
|
|
168
|
+
for result in notify_rx {
|
|
169
|
+
match result {
|
|
170
|
+
Ok(event) => {
|
|
171
|
+
let now = Instant::now();
|
|
172
|
+
for path in &event.paths {
|
|
173
|
+
let fname = match path.file_name() {
|
|
174
|
+
Some(f) => f,
|
|
175
|
+
None => continue,
|
|
176
|
+
};
|
|
177
|
+
if (fname == db_name || fname == wal_name)
|
|
178
|
+
&& now.duration_since(last_db) > debounce
|
|
179
|
+
{
|
|
180
|
+
last_db = now;
|
|
181
|
+
let _ = tx.send(BroadcastMessage::db_change().to_json());
|
|
182
|
+
} else if fname == test_name
|
|
183
|
+
&& now.duration_since(last_test) > debounce
|
|
184
|
+
{
|
|
185
|
+
last_test = now;
|
|
186
|
+
let _ = tx.send(BroadcastMessage::test_change().to_json());
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
Err(e) => log::debug!("File watch error: {e}"),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
|
|
195
|
+
|
|
196
|
+
Ok(())
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Mtime tracking (polling fallback)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/// Tracks file modification times for change detection.
|
|
204
|
+
#[derive(Debug, Default)]
|
|
205
|
+
struct MtimeTracker {
|
|
206
|
+
db: Option<u64>,
|
|
207
|
+
wal: Option<u64>,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// Get the mtime of a file in milliseconds, or `None` if it doesn't exist.
|
|
211
|
+
fn file_mtime_ms(path: &Path) -> Option<u64> {
|
|
212
|
+
std::fs::metadata(path)
|
|
213
|
+
.ok()
|
|
214
|
+
.and_then(|m| m.modified().ok())
|
|
215
|
+
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
|
216
|
+
.map(|d| d.as_millis() as u64)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Check if the database files have changed since the last poll.
|
|
220
|
+
fn check_db_changed(db_path: &Path, last: &mut MtimeTracker) -> bool {
|
|
221
|
+
let current_db = file_mtime_ms(db_path);
|
|
222
|
+
let wal_path = db_path.with_extension("db-wal");
|
|
223
|
+
let current_wal = file_mtime_ms(&wal_path);
|
|
224
|
+
|
|
225
|
+
let changed = current_db != last.db || current_wal != last.wal;
|
|
226
|
+
last.db = current_db;
|
|
227
|
+
last.wal = current_wal;
|
|
228
|
+
changed
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// Check if a single file has changed since the last poll.
|
|
232
|
+
fn check_file_changed(path: &Path, last: &mut Option<u64>) -> bool {
|
|
233
|
+
let current = file_mtime_ms(path);
|
|
234
|
+
let changed = current != *last;
|
|
235
|
+
*last = current;
|
|
236
|
+
changed
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Server config
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/// Configuration for the WebSocket server.
|
|
244
|
+
#[derive(Debug, Clone)]
|
|
245
|
+
pub struct WsConfig {
|
|
246
|
+
/// Port to listen on (default: 47808).
|
|
247
|
+
pub port: u16,
|
|
248
|
+
/// Project root directory containing `.jettypod/`.
|
|
249
|
+
pub project_root: PathBuf,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
impl Default for WsConfig {
|
|
253
|
+
fn default() -> Self {
|
|
254
|
+
Self {
|
|
255
|
+
port: DEFAULT_PORT,
|
|
256
|
+
project_root: PathBuf::from("."),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Server
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/// State shared between the accept loop and watcher tasks.
|
|
266
|
+
struct ServerState {
|
|
267
|
+
/// Broadcast channel for sending messages to all clients.
|
|
268
|
+
broadcast_tx: broadcast::Sender<String>,
|
|
269
|
+
/// Count of connected clients (for stats).
|
|
270
|
+
client_count: RwLock<usize>,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// The WebSocket server.
|
|
274
|
+
pub struct WsServer {
|
|
275
|
+
config: WsConfig,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
impl WsServer {
|
|
279
|
+
pub fn new(config: WsConfig) -> Self {
|
|
280
|
+
Self { config }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Run the server with an externally-created broadcast sender.
|
|
284
|
+
///
|
|
285
|
+
/// The sender is shared with callers (e.g., SQLite update_hook) so they
|
|
286
|
+
/// can inject messages directly without going through file watching.
|
|
287
|
+
///
|
|
288
|
+
/// Spawns:
|
|
289
|
+
/// 1. TCP accept loop (handles new WS connections)
|
|
290
|
+
/// 2. File watcher thread (broadcasts db_change/test_change via native OS events)
|
|
291
|
+
/// Falls back to mtime polling if native watching is unavailable.
|
|
292
|
+
pub async fn run(&self, broadcast_tx: broadcast::Sender<String>) -> Result<()> {
|
|
293
|
+
let addr = format!("127.0.0.1:{}", self.config.port);
|
|
294
|
+
let listener = TcpListener::bind(&addr)
|
|
295
|
+
.await
|
|
296
|
+
.map_err(|e| CoreError::Other(format!("ws bind failed on {addr}: {e}")))?;
|
|
297
|
+
|
|
298
|
+
let state = Arc::new(ServerState {
|
|
299
|
+
broadcast_tx: broadcast_tx.clone(),
|
|
300
|
+
client_count: RwLock::new(0),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
let db_path = self
|
|
304
|
+
.config
|
|
305
|
+
.project_root
|
|
306
|
+
.join(".jettypod")
|
|
307
|
+
.join("work.db");
|
|
308
|
+
let test_results_path = self.config.project_root.join("cucumber-results.json");
|
|
309
|
+
|
|
310
|
+
// Try native file watching, fall back to mtime polling if unavailable.
|
|
311
|
+
match setup_file_watcher(&db_path, &test_results_path, &broadcast_tx) {
|
|
312
|
+
Ok(()) => log::info!("Using native file watching for change detection"),
|
|
313
|
+
Err(e) => {
|
|
314
|
+
log::warn!("Native file watch unavailable ({e}), using mtime polling");
|
|
315
|
+
let tx_db = broadcast_tx.clone();
|
|
316
|
+
let db_path_clone = db_path.clone();
|
|
317
|
+
tokio::spawn(async move {
|
|
318
|
+
poll_db_changes(&db_path_clone, tx_db).await;
|
|
319
|
+
});
|
|
320
|
+
let tx_test = broadcast_tx.clone();
|
|
321
|
+
let test_path_clone = test_results_path.clone();
|
|
322
|
+
tokio::spawn(async move {
|
|
323
|
+
poll_test_changes(&test_path_clone, tx_test).await;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Accept loop
|
|
329
|
+
loop {
|
|
330
|
+
let (stream, _addr) = listener
|
|
331
|
+
.accept()
|
|
332
|
+
.await
|
|
333
|
+
.map_err(|e| CoreError::Other(format!("ws accept error: {e}")))?;
|
|
334
|
+
|
|
335
|
+
let state = state.clone();
|
|
336
|
+
tokio::spawn(async move {
|
|
337
|
+
if let Err(e) = handle_connection(stream, state).await {
|
|
338
|
+
log::debug!("ws connection error: {e}");
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// Start a WS server on a background thread with its own tokio runtime.
|
|
346
|
+
///
|
|
347
|
+
/// Returns the broadcast sender so callers (e.g., SQLite update_hook) can
|
|
348
|
+
/// inject messages directly into the WebSocket broadcast channel.
|
|
349
|
+
///
|
|
350
|
+
/// This works regardless of whether a tokio runtime already exists on the
|
|
351
|
+
/// calling thread (CLI) or not (Tauri IPC handlers).
|
|
352
|
+
pub fn spawn_server(config: WsConfig) -> (std::thread::JoinHandle<()>, broadcast::Sender<String>) {
|
|
353
|
+
let (broadcast_tx, _) = broadcast::channel::<String>(64);
|
|
354
|
+
let tx_for_server = broadcast_tx.clone();
|
|
355
|
+
|
|
356
|
+
let handle = std::thread::Builder::new()
|
|
357
|
+
.name("jettypod-ws".into())
|
|
358
|
+
.spawn(move || {
|
|
359
|
+
let rt = tokio::runtime::Runtime::new()
|
|
360
|
+
.expect("Failed to create tokio runtime for WS server");
|
|
361
|
+
rt.block_on(async {
|
|
362
|
+
let server = WsServer::new(config);
|
|
363
|
+
if let Err(e) = server.run(tx_for_server).await {
|
|
364
|
+
log::error!("WebSocket server error: {e}");
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
})
|
|
368
|
+
.expect("Failed to spawn WS server thread");
|
|
369
|
+
|
|
370
|
+
(handle, broadcast_tx)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Connection handler
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
async fn handle_connection(
|
|
378
|
+
stream: TcpStream,
|
|
379
|
+
state: Arc<ServerState>,
|
|
380
|
+
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
381
|
+
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
|
382
|
+
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
|
|
383
|
+
|
|
384
|
+
// Increment client count
|
|
385
|
+
{
|
|
386
|
+
let mut count = state.client_count.write().await;
|
|
387
|
+
*count += 1;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Send connected message
|
|
391
|
+
let connected = BroadcastMessage::connected().to_json();
|
|
392
|
+
ws_sender.send(WsMessage::Text(connected.into())).await?;
|
|
393
|
+
|
|
394
|
+
// Subscribe to broadcast channel
|
|
395
|
+
let mut rx = state.broadcast_tx.subscribe();
|
|
396
|
+
|
|
397
|
+
// Two tasks: forward broadcasts to client, and drain incoming messages
|
|
398
|
+
let send_task = tokio::spawn(async move {
|
|
399
|
+
while let Ok(msg) = rx.recv().await {
|
|
400
|
+
if ws_sender.send(WsMessage::Text(msg.into())).await.is_err() {
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
let recv_task = tokio::spawn(async move {
|
|
407
|
+
// We don't process incoming messages, just drain them
|
|
408
|
+
while let Some(Ok(_msg)) = ws_receiver.next().await {}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Wait for either to finish (client disconnect)
|
|
412
|
+
tokio::select! {
|
|
413
|
+
_ = send_task => {},
|
|
414
|
+
_ = recv_task => {},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Decrement client count
|
|
418
|
+
{
|
|
419
|
+
let mut count = state.client_count.write().await;
|
|
420
|
+
*count = count.saturating_sub(1);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
Ok(())
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Polling fallback
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
async fn poll_db_changes(db_path: &Path, tx: broadcast::Sender<String>) {
|
|
431
|
+
let mut tracker = MtimeTracker::default();
|
|
432
|
+
let interval = Duration::from_millis(POLL_MS);
|
|
433
|
+
|
|
434
|
+
// Initialize tracker with current state (skip first "change")
|
|
435
|
+
let _ = check_db_changed(db_path, &mut tracker);
|
|
436
|
+
|
|
437
|
+
loop {
|
|
438
|
+
tokio::time::sleep(interval).await;
|
|
439
|
+
|
|
440
|
+
if check_db_changed(db_path, &mut tracker) {
|
|
441
|
+
let msg = BroadcastMessage::db_change().to_json();
|
|
442
|
+
if tx.send(msg).is_err() {
|
|
443
|
+
log::trace!("No active WebSocket receivers for db change");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async fn poll_test_changes(test_path: &Path, tx: broadcast::Sender<String>) {
|
|
450
|
+
let mut last_mtime: Option<u64> = None;
|
|
451
|
+
let interval = Duration::from_millis(POLL_MS);
|
|
452
|
+
|
|
453
|
+
// Initialize
|
|
454
|
+
let _ = check_file_changed(test_path, &mut last_mtime);
|
|
455
|
+
|
|
456
|
+
loop {
|
|
457
|
+
tokio::time::sleep(interval).await;
|
|
458
|
+
|
|
459
|
+
if check_file_changed(test_path, &mut last_mtime) {
|
|
460
|
+
let msg = BroadcastMessage::test_change().to_json();
|
|
461
|
+
if tx.send(msg).is_err() {
|
|
462
|
+
log::trace!("No active WebSocket receivers for test change");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Tests
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
#[cfg(test)]
|
|
473
|
+
mod tests {
|
|
474
|
+
use super::*;
|
|
475
|
+
use tempfile::TempDir;
|
|
476
|
+
|
|
477
|
+
#[test]
|
|
478
|
+
fn broadcast_message_serialization() {
|
|
479
|
+
let msg = BroadcastMessage::db_change();
|
|
480
|
+
let json = msg.to_json();
|
|
481
|
+
assert!(json.contains("\"type\":\"db_change\""));
|
|
482
|
+
assert!(json.contains("\"timestamp\""));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn connected_message() {
|
|
487
|
+
let msg = BroadcastMessage::connected();
|
|
488
|
+
let json = msg.to_json();
|
|
489
|
+
assert!(json.contains("\"type\":\"connected\""));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#[test]
|
|
493
|
+
fn test_change_message() {
|
|
494
|
+
let msg = BroadcastMessage::test_change();
|
|
495
|
+
let json = msg.to_json();
|
|
496
|
+
assert!(json.contains("\"type\":\"test_change\""));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#[test]
|
|
500
|
+
fn mtime_tracker_detects_changes() {
|
|
501
|
+
let dir = TempDir::new().unwrap();
|
|
502
|
+
let db_path = dir.path().join("work.db");
|
|
503
|
+
std::fs::write(&db_path, "initial").unwrap();
|
|
504
|
+
|
|
505
|
+
let mut tracker = MtimeTracker::default();
|
|
506
|
+
|
|
507
|
+
// First call initializes — always "changed"
|
|
508
|
+
assert!(check_db_changed(&db_path, &mut tracker));
|
|
509
|
+
|
|
510
|
+
// No change
|
|
511
|
+
assert!(!check_db_changed(&db_path, &mut tracker));
|
|
512
|
+
|
|
513
|
+
// Modify file
|
|
514
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
515
|
+
std::fs::write(&db_path, "modified").unwrap();
|
|
516
|
+
assert!(check_db_changed(&db_path, &mut tracker));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#[test]
|
|
520
|
+
fn file_changed_tracker() {
|
|
521
|
+
let dir = TempDir::new().unwrap();
|
|
522
|
+
let path = dir.path().join("results.json");
|
|
523
|
+
|
|
524
|
+
let mut last: Option<u64> = None;
|
|
525
|
+
|
|
526
|
+
// File doesn't exist — no change from None to None
|
|
527
|
+
assert!(!check_file_changed(&path, &mut last));
|
|
528
|
+
|
|
529
|
+
// Create file
|
|
530
|
+
std::fs::write(&path, "{}").unwrap();
|
|
531
|
+
assert!(check_file_changed(&path, &mut last));
|
|
532
|
+
|
|
533
|
+
// No change
|
|
534
|
+
assert!(!check_file_changed(&path, &mut last));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[test]
|
|
538
|
+
fn now_millis_returns_positive() {
|
|
539
|
+
assert!(now_millis() > 0);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#[test]
|
|
543
|
+
fn db_delta_message_serialization() {
|
|
544
|
+
let msg = BroadcastMessage::db_delta("update", "work_items", 42);
|
|
545
|
+
let json = msg.to_json();
|
|
546
|
+
assert!(json.contains("\"type\":\"db_delta\""));
|
|
547
|
+
assert!(json.contains("\"table\":\"work_items\""));
|
|
548
|
+
assert!(json.contains("\"rowid\":42"));
|
|
549
|
+
assert!(json.contains("\"action\":\"update\""));
|
|
550
|
+
assert!(json.contains("\"timestamp\""));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#[test]
|
|
554
|
+
fn db_delta_insert_action() {
|
|
555
|
+
let msg = BroadcastMessage::db_delta("insert", "work_items", 1);
|
|
556
|
+
let json = msg.to_json();
|
|
557
|
+
assert!(json.contains("\"action\":\"insert\""));
|
|
558
|
+
assert!(json.contains("\"rowid\":1"));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#[test]
|
|
562
|
+
fn db_delta_delete_action() {
|
|
563
|
+
let msg = BroadcastMessage::db_delta("delete", "work_items", 99);
|
|
564
|
+
let json = msg.to_json();
|
|
565
|
+
assert!(json.contains("\"action\":\"delete\""));
|
|
566
|
+
assert!(json.contains("\"rowid\":99"));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#[test]
|
|
570
|
+
fn db_change_omits_delta_fields() {
|
|
571
|
+
let msg = BroadcastMessage::db_change();
|
|
572
|
+
let json = msg.to_json();
|
|
573
|
+
assert!(!json.contains("\"table\""));
|
|
574
|
+
assert!(!json.contains("\"rowid\""));
|
|
575
|
+
assert!(!json.contains("\"action\""));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
#[test]
|
|
579
|
+
fn connected_message_omits_delta_fields() {
|
|
580
|
+
let msg = BroadcastMessage::connected();
|
|
581
|
+
let json = msg.to_json();
|
|
582
|
+
assert!(!json.contains("\"table\""));
|
|
583
|
+
assert!(!json.contains("\"rowid\""));
|
|
584
|
+
assert!(!json.contains("\"action\""));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#[test]
|
|
588
|
+
fn test_change_omits_delta_fields() {
|
|
589
|
+
let msg = BroadcastMessage::test_change();
|
|
590
|
+
let json = msg.to_json();
|
|
591
|
+
assert!(!json.contains("\"table\""));
|
|
592
|
+
assert!(!json.contains("\"rowid\""));
|
|
593
|
+
assert!(!json.contains("\"action\""));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
#[test]
|
|
597
|
+
fn db_delta_roundtrips_through_json() {
|
|
598
|
+
let msg = BroadcastMessage::db_delta("update", "work_items", 7);
|
|
599
|
+
let json = msg.to_json();
|
|
600
|
+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
601
|
+
assert_eq!(parsed["type"], "db_delta");
|
|
602
|
+
assert_eq!(parsed["table"], "work_items");
|
|
603
|
+
assert_eq!(parsed["rowid"], 7);
|
|
604
|
+
assert_eq!(parsed["action"], "update");
|
|
605
|
+
assert!(parsed["timestamp"].as_u64().unwrap() > 0);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#[tokio::test]
|
|
609
|
+
async fn server_binds_and_accepts() {
|
|
610
|
+
let addr = "127.0.0.1:0";
|
|
611
|
+
let listener = TcpListener::bind(addr).await.unwrap();
|
|
612
|
+
let local_addr = listener.local_addr().unwrap();
|
|
613
|
+
assert!(local_addr.port() > 0);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
#[test]
|
|
617
|
+
fn file_watcher_initializes() {
|
|
618
|
+
let dir = TempDir::new().unwrap();
|
|
619
|
+
let db_path = dir.path().join("work.db");
|
|
620
|
+
let test_path = dir.path().join("cucumber-results.json");
|
|
621
|
+
std::fs::write(&db_path, "test").unwrap();
|
|
622
|
+
|
|
623
|
+
let (tx, _rx) = broadcast::channel::<String>(16);
|
|
624
|
+
let result = setup_file_watcher(&db_path, &test_path, &tx);
|
|
625
|
+
assert!(result.is_ok(), "File watcher should initialize: {result:?}");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
#[test]
|
|
629
|
+
fn file_watcher_detects_db_change() {
|
|
630
|
+
let dir = TempDir::new().unwrap();
|
|
631
|
+
let db_path = dir.path().join("work.db");
|
|
632
|
+
let test_path = dir.path().join("cucumber-results.json");
|
|
633
|
+
std::fs::write(&db_path, "initial").unwrap();
|
|
634
|
+
|
|
635
|
+
let (tx, mut rx) = broadcast::channel::<String>(16);
|
|
636
|
+
setup_file_watcher(&db_path, &test_path, &tx).unwrap();
|
|
637
|
+
|
|
638
|
+
// Give watcher time to start
|
|
639
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
640
|
+
|
|
641
|
+
// Modify the db file
|
|
642
|
+
std::fs::write(&db_path, "modified").unwrap();
|
|
643
|
+
|
|
644
|
+
// Wait for event with timeout
|
|
645
|
+
let deadline = Instant::now() + Duration::from_secs(2);
|
|
646
|
+
loop {
|
|
647
|
+
match rx.try_recv() {
|
|
648
|
+
Ok(msg) => {
|
|
649
|
+
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
|
|
650
|
+
assert_eq!(parsed["type"], "db_change");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
Err(broadcast::error::TryRecvError::Empty) => {
|
|
654
|
+
if Instant::now() > deadline {
|
|
655
|
+
panic!("Timed out waiting for db_change event");
|
|
656
|
+
}
|
|
657
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
658
|
+
}
|
|
659
|
+
Err(e) => panic!("Unexpected recv error: {e}"),
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
#[test]
|
|
665
|
+
fn file_watcher_detects_test_change() {
|
|
666
|
+
let dir = TempDir::new().unwrap();
|
|
667
|
+
let db_path = dir.path().join("work.db");
|
|
668
|
+
let test_path = dir.path().join("cucumber-results.json");
|
|
669
|
+
std::fs::write(&db_path, "db").unwrap();
|
|
670
|
+
|
|
671
|
+
let (tx, mut rx) = broadcast::channel::<String>(16);
|
|
672
|
+
setup_file_watcher(&db_path, &test_path, &tx).unwrap();
|
|
673
|
+
|
|
674
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
675
|
+
|
|
676
|
+
// Create the test results file
|
|
677
|
+
std::fs::write(&test_path, "{\"results\":[]}").unwrap();
|
|
678
|
+
|
|
679
|
+
let deadline = Instant::now() + Duration::from_secs(2);
|
|
680
|
+
loop {
|
|
681
|
+
match rx.try_recv() {
|
|
682
|
+
Ok(msg) => {
|
|
683
|
+
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
|
|
684
|
+
assert_eq!(parsed["type"], "test_change");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
Err(broadcast::error::TryRecvError::Empty) => {
|
|
688
|
+
if Instant::now() > deadline {
|
|
689
|
+
panic!("Timed out waiting for test_change event");
|
|
690
|
+
}
|
|
691
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
692
|
+
}
|
|
693
|
+
Err(e) => panic!("Unexpected recv error: {e}"),
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
#[test]
|
|
699
|
+
fn file_watcher_debounces_rapid_changes() {
|
|
700
|
+
let dir = TempDir::new().unwrap();
|
|
701
|
+
let db_path = dir.path().join("work.db");
|
|
702
|
+
let test_path = dir.path().join("cucumber-results.json");
|
|
703
|
+
std::fs::write(&db_path, "initial").unwrap();
|
|
704
|
+
|
|
705
|
+
let (tx, mut rx) = broadcast::channel::<String>(64);
|
|
706
|
+
setup_file_watcher(&db_path, &test_path, &tx).unwrap();
|
|
707
|
+
|
|
708
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
709
|
+
|
|
710
|
+
// Rapid-fire writes (simulating SQLite WAL checkpoint)
|
|
711
|
+
for i in 0..10 {
|
|
712
|
+
std::fs::write(&db_path, format!("write-{i}")).unwrap();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Wait for events to settle
|
|
716
|
+
std::thread::sleep(Duration::from_millis(500));
|
|
717
|
+
|
|
718
|
+
// Count received events — should be much less than 10 due to debouncing
|
|
719
|
+
let mut count = 0;
|
|
720
|
+
while rx.try_recv().is_ok() {
|
|
721
|
+
count += 1;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
assert!(
|
|
725
|
+
count < 10,
|
|
726
|
+
"Expected debouncing to reduce events to fewer than 10, got {count}"
|
|
727
|
+
);
|
|
728
|
+
assert!(count >= 1, "Expected at least 1 event, got {count}");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#[test]
|
|
732
|
+
fn file_watcher_separates_db_and_test_events() {
|
|
733
|
+
let dir = TempDir::new().unwrap();
|
|
734
|
+
let db_path = dir.path().join("work.db");
|
|
735
|
+
let test_path = dir.path().join("cucumber-results.json");
|
|
736
|
+
std::fs::write(&db_path, "db-initial").unwrap();
|
|
737
|
+
std::fs::write(&test_path, "test-initial").unwrap();
|
|
738
|
+
|
|
739
|
+
let (tx, mut rx) = broadcast::channel::<String>(64);
|
|
740
|
+
setup_file_watcher(&db_path, &test_path, &tx).unwrap();
|
|
741
|
+
|
|
742
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
743
|
+
|
|
744
|
+
// Modify db file
|
|
745
|
+
std::fs::write(&db_path, "db-modified").unwrap();
|
|
746
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
747
|
+
|
|
748
|
+
// Modify test file
|
|
749
|
+
std::fs::write(&test_path, "test-modified").unwrap();
|
|
750
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
751
|
+
|
|
752
|
+
// Collect all events
|
|
753
|
+
let mut db_events = 0;
|
|
754
|
+
let mut test_events = 0;
|
|
755
|
+
while let Ok(msg) = rx.try_recv() {
|
|
756
|
+
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
|
|
757
|
+
match parsed["type"].as_str().unwrap() {
|
|
758
|
+
"db_change" => db_events += 1,
|
|
759
|
+
"test_change" => test_events += 1,
|
|
760
|
+
other => panic!("Unexpected event type: {other}"),
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
assert!(db_events >= 1, "Expected at least 1 db_change, got {db_events}");
|
|
765
|
+
assert!(test_events >= 1, "Expected at least 1 test_change, got {test_events}");
|
|
766
|
+
}
|
|
767
|
+
}
|