diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml
index 7d5432f4..afe776bd 100644
--- a/.github/workflows/mac.yml
+++ b/.github/workflows/mac.yml
@@ -121,7 +121,7 @@ jobs:
run: |
echo "Resolving Swift package dependencies..."
# Workspace is at root level
- xcodebuild -resolvePackageDependencies -workspace VibeTunnel.xcworkspace || echo "Dependency resolution completed"
+ xcodebuild -resolvePackageDependencies -workspace VibeTunnel.xcworkspace -scheme VibeTunnel-Mac || echo "Dependency resolution completed"
# Debug: List available schemes
echo "=== Available schemes ==="
diff --git a/README.md b/README.md
index 80ca21d8..2737d5d6 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift
index 5ae4e9aa..c1a08cb2 100644
--- a/mac/VibeTunnel/Core/Services/ServerManager.swift
+++ b/mac/VibeTunnel/Core/Services/ServerManager.swift
@@ -76,7 +76,8 @@ class ServerManager {
// Log for debugging
// logger
// .debug(
- // "bindAddress getter: rawValue='\(rawValue)', mode=\(mode.rawValue), bindAddress=\(mode.bindAddress)"
+ // "bindAddress getter: rawValue='\(rawValue)', mode=\(mode.rawValue),
+ // bindAddress=\(mode.bindAddress)"
// )
return mode.bindAddress
diff --git a/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift b/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift
index 4d1ff74c..b69b3664 100644
--- a/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift
+++ b/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift
@@ -209,7 +209,7 @@ struct AutocompleteTextField: View {
@State private var keyboardNavigating = false
@State private var textFieldSize: CGSize = .zero
-
+
var body: some View {
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
@@ -266,10 +266,10 @@ struct AutocompleteTextField: View {
)
)
)
- .onAppear {
- // Initialize autocompleteService with GitRepositoryMonitor
- autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
- }
+ .onAppear {
+ // Initialize autocompleteService with GitRepositoryMonitor
+ autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
+ }
}
private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
diff --git a/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift b/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift
index acbd993a..0c83bf7d 100644
--- a/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift
+++ b/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift
@@ -1,5 +1,5 @@
-import SwiftUI
import AppKit
+import SwiftUI
/// Simple NSWindow-based dropdown for autocomplete
struct AutocompleteWindowView: NSViewRepresentable {
@@ -9,13 +9,13 @@ struct AutocompleteWindowView: NSViewRepresentable {
let onSelect: (String) -> Void
let width: CGFloat
@Binding var isShowing: Bool
-
+
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
return view
}
-
+
func updateNSView(_ nsView: NSView, context: Context) {
if isShowing && !suggestions.isEmpty {
context.coordinator.showDropdown(
@@ -29,11 +29,11 @@ struct AutocompleteWindowView: NSViewRepresentable {
context.coordinator.hideDropdown()
}
}
-
+
func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect, isShowing: $isShowing, selectedIndex: $selectedIndex)
}
-
+
@MainActor
class Coordinator: NSObject {
private var dropdownWindow: NSWindow?
@@ -41,15 +41,15 @@ struct AutocompleteWindowView: NSViewRepresentable {
private let onSelect: (String) -> Void
@Binding var isShowing: Bool
@Binding var selectedIndex: Int
- nonisolated(unsafe) private var clickMonitor: Any?
-
+ private nonisolated(unsafe) var clickMonitor: Any?
+
init(onSelect: @escaping (String) -> Void, isShowing: Binding, selectedIndex: Binding) {
self.onSelect = onSelect
self._isShowing = isShowing
self._selectedIndex = selectedIndex
super.init()
}
-
+
deinit {
if let monitor = clickMonitor {
DispatchQueue.main.async {
@@ -57,7 +57,7 @@ struct AutocompleteWindowView: NSViewRepresentable {
}
}
}
-
+
@MainActor
private func cleanupClickMonitor() {
if let monitor = clickMonitor {
@@ -65,7 +65,7 @@ struct AutocompleteWindowView: NSViewRepresentable {
clickMonitor = nil
}
}
-
+
@MainActor
func showDropdown(
on view: NSView,
@@ -75,7 +75,7 @@ struct AutocompleteWindowView: NSViewRepresentable {
width: CGFloat
) {
guard let parentWindow = view.window else { return }
-
+
// Create window if needed
if dropdownWindow == nil {
let window = NSWindow(
@@ -84,23 +84,23 @@ struct AutocompleteWindowView: NSViewRepresentable {
backing: .buffered,
defer: false
)
-
+
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.level = .floating
window.isReleasedWhenClosed = false
-
+
let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
window.contentView = hostingView
-
+
self.dropdownWindow = window
self.hostingView = hostingView
}
-
+
guard let window = dropdownWindow,
- let hostingView = hostingView else { return }
-
+ let hostingView else { return }
+
// Update content with proper binding
let content = VStack(spacing: 0) {
AutocompleteViewWithKeyboard(
@@ -120,13 +120,13 @@ struct AutocompleteWindowView: NSViewRepresentable {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
-
+
hostingView.rootView = AnyView(content)
-
+
// Position window below the text field
let viewFrame = view.convert(view.bounds, to: nil)
let screenFrame = parentWindow.convertToScreen(viewFrame)
-
+
// Calculate window position
let windowFrame = NSRect(
x: screenFrame.minX,
@@ -134,15 +134,15 @@ struct AutocompleteWindowView: NSViewRepresentable {
width: width,
height: 200
)
-
+
window.setFrame(windowFrame, display: false)
-
+
// Show window
if window.parent == nil {
parentWindow.addChildWindow(window, ordered: .above)
}
window.makeKeyAndOrderFront(nil)
-
+
// Setup click monitoring
if clickMonitor == nil {
clickMonitor = NSEvent.addLocalMonitorForEvents(
@@ -155,11 +155,11 @@ struct AutocompleteWindowView: NSViewRepresentable {
}
}
}
-
+
@MainActor
func hideDropdown() {
cleanupClickMonitor()
-
+
if let window = dropdownWindow {
if let parent = window.parent {
parent.removeChildWindow(window)
@@ -168,4 +168,4 @@ struct AutocompleteWindowView: NSViewRepresentable {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift
index 372506bf..9d95ede9 100644
--- a/mac/VibeTunnel/VibeTunnelApp.swift
+++ b/mac/VibeTunnel/VibeTunnelApp.swift
@@ -10,6 +10,7 @@ import UserNotifications
/// across all windows and handles deep linking for terminal session URLs.
///
/// This application runs on macOS 14.0+ and requires Swift 6.
+/// The app provides terminal access through web browsers.
@main
struct VibeTunnelApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self)
diff --git a/web/scripts/test-server.js b/web/scripts/test-server.js
index e145bd21..9504d929 100755
--- a/web/scripts/test-server.js
+++ b/web/scripts/test-server.js
@@ -147,6 +147,7 @@ child.on('error', (error) => {
// Log when process starts
child.on('spawn', () => {
console.log('Server process spawned successfully');
+ console.log(`Server PID: ${child.pid}`);
});
// Handle early exit
@@ -204,6 +205,25 @@ if (process.env.CI || process.env.WAIT_FOR_SERVER) {
process.exit(1);
} else {
console.log('Server is ready, tests can proceed');
+
+ // In CI, add periodic health checks
+ if (process.env.CI) {
+ const healthCheckInterval = setInterval(() => {
+ if (hasExited) {
+ clearInterval(healthCheckInterval);
+ return;
+ }
+
+ const http = require('http');
+ http.get(`http://localhost:${port}/api/health`, (res) => {
+ if (res.statusCode !== 200) {
+ console.error(`Health check failed with status ${res.statusCode}`);
+ }
+ }).on('error', (err) => {
+ console.error(`Health check error: ${err.message}`);
+ });
+ }, 10000); // Check every 10 seconds
+ }
}
});
}, 3000); // Wait 3 seconds before checking
diff --git a/web/src/server/server.ts b/web/src/server/server.ts
index 94628d77..d28d6ab3 100644
--- a/web/src/server/server.ts
+++ b/web/src/server/server.ts
@@ -1,3 +1,4 @@
+// VibeTunnel server entry point
import chalk from 'chalk';
import compression from 'compression';
import type { Response as ExpressResponse } from 'express';
diff --git a/web/src/test/playwright/helpers/test-data-manager.helper.ts b/web/src/test/playwright/helpers/test-data-manager.helper.ts
index 5312cc63..921ebad6 100644
--- a/web/src/test/playwright/helpers/test-data-manager.helper.ts
+++ b/web/src/test/playwright/helpers/test-data-manager.helper.ts
@@ -40,7 +40,34 @@ export class TestSessionManager {
let sessionId = '';
if (!spawnWindow) {
console.log(`Web session created, waiting for navigation to session view...`);
- await this.page.waitForURL(/\/session\//, { timeout: 10000 });
+ // Increase timeout for CI and add retry logic
+ const timeout = process.env.CI ? 30000 : 15000;
+
+ // Add a small delay to ensure navigation starts
+ await this.page.waitForTimeout(500);
+
+ try {
+ await this.page.waitForURL(/\/session\//, { timeout });
+ } catch (error) {
+ // If waitForURL times out, check if we're already on a session page
+ const currentUrl = this.page.url();
+ if (!currentUrl.includes('/session/')) {
+ // Try to wait for any navigation
+ try {
+ await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
+ } catch (_loadError) {
+ // Ignore load state error
+ }
+ const finalUrl = this.page.url();
+ if (!finalUrl.includes('/session/')) {
+ console.error(
+ `Navigation failed. Initial URL: ${currentUrl}, Final URL: ${finalUrl}`
+ );
+ throw error;
+ }
+ }
+ console.log(`Already on session page: ${currentUrl}`);
+ }
const url = this.page.url();
if (!url.includes('/session/')) {
diff --git a/web/src/test/playwright/pages/session-list.page.ts b/web/src/test/playwright/pages/session-list.page.ts
index dc49cec3..582ebda9 100644
--- a/web/src/test/playwright/pages/session-list.page.ts
+++ b/web/src/test/playwright/pages/session-list.page.ts
@@ -443,7 +443,7 @@ export class SessionListPage extends BasePage {
if (sessionId) {
await this.page.goto(`/session/${sessionId}`, {
waitUntil: 'domcontentloaded',
- timeout: 15000, // Increase timeout for CI
+ timeout: process.env.CI ? 30000 : 15000, // Increase timeout for CI
});
} else {
// Wait for automatic navigation
diff --git a/web/src/test/playwright/specs/terminal-interaction.spec.ts b/web/src/test/playwright/specs/terminal-interaction.spec.ts
index 11db9f30..93d5f140 100644
--- a/web/src/test/playwright/specs/terminal-interaction.spec.ts
+++ b/web/src/test/playwright/specs/terminal-interaction.spec.ts
@@ -23,14 +23,22 @@ test.describe('Terminal Interaction', () => {
// Use unique prefix for this test file to prevent session conflicts
sessionManager = new TestSessionManager(page, 'termint');
+ // Add network error logging for debugging
+ page.on('requestfailed', (request) => {
+ console.error(`Request failed: ${request.url()} - ${request.failure()?.errorText}`);
+ });
+
// Create a session for all tests using the session manager to ensure proper tracking
const sessionData = await sessionManager.createTrackedSession('terminal-test');
- // Navigate to the created session
- await page.goto(`/session/${sessionData.sessionId}`, { waitUntil: 'domcontentloaded' });
+ // Navigate to the created session with increased timeout for CI
+ await page.goto(`/session/${sessionData.sessionId}`, {
+ waitUntil: 'domcontentloaded',
+ timeout: process.env.CI ? 30000 : 15000,
+ });
// Wait for terminal with proper WebSocket handling
- await waitForTerminalReady(page, 10000);
+ await waitForTerminalReady(page, process.env.CI ? 20000 : 10000);
});
test.afterEach(async () => {
diff --git a/web/src/test/unit/asciinema-writer.test.ts b/web/src/test/unit/asciinema-writer.test.ts
index a4391a20..3b52a86b 100644
--- a/web/src/test/unit/asciinema-writer.test.ts
+++ b/web/src/test/unit/asciinema-writer.test.ts
@@ -28,8 +28,20 @@ describe('AsciinemaWriter byte position tracking', () => {
it('should track byte position correctly for header', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24, 'test command', 'Test Title');
- // Give the header time to be written
- await new Promise((resolve) => setTimeout(resolve, 10));
+ // Wait for the header to be written with a polling mechanism
+ let attempts = 0;
+ const maxAttempts = 50; // 50 * 10ms = 500ms max wait
+
+ while (attempts < maxAttempts) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ const position = writer.getPosition();
+ if (position.written > 0 && position.pending === 0) {
+ // Header has been written
+ break;
+ }
+ attempts++;
+ }
const position = writer.getPosition();
expect(position.written).toBeGreaterThan(0);