From 9d7fe3669977e8d92fab833f65ef2fe4820145bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Jul 2025 09:19:40 +0200 Subject: [PATCH] Fix CI issues (#476) --- .github/workflows/mac.yml | 2 +- README.md | 2 +- .../Core/Services/ServerManager.swift | 3 +- .../Components/AutocompleteView.swift | 10 ++-- .../Components/AutocompleteWindow.swift | 52 +++++++++---------- mac/VibeTunnel/VibeTunnelApp.swift | 1 + web/scripts/test-server.js | 20 +++++++ web/src/server/server.ts | 1 + .../helpers/test-data-manager.helper.ts | 29 ++++++++++- .../playwright/pages/session-list.page.ts | 2 +- .../specs/terminal-interaction.spec.ts | 14 +++-- web/src/test/unit/asciinema-writer.test.ts | 16 +++++- 12 files changed, 111 insertions(+), 41 deletions(-) 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 @@ - +

VibeTunnel Banner

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);