mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix CI issues (#476)
This commit is contained in:
parent
acdc4f22a8
commit
9d7fe36699
12 changed files with 111 additions and 41 deletions
2
.github/workflows/mac.yml
vendored
2
.github/workflows/mac.yml
vendored
|
|
@ -121,7 +121,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
echo "Resolving Swift package dependencies..."
|
echo "Resolving Swift package dependencies..."
|
||||||
# Workspace is at root level
|
# 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
|
# Debug: List available schemes
|
||||||
echo "=== Available schemes ==="
|
echo "=== Available schemes ==="
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<!-- Generated: 2025-07-26 18:45:00 UTC -->
|
<!-- Generated: 2025-07-28 12:35:00 UTC -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/banner.png" alt="VibeTunnel Banner" />
|
<img src="assets/banner.png" alt="VibeTunnel Banner" />
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ class ServerManager {
|
||||||
// Log for debugging
|
// Log for debugging
|
||||||
// logger
|
// logger
|
||||||
// .debug(
|
// .debug(
|
||||||
// "bindAddress getter: rawValue='\(rawValue)', mode=\(mode.rawValue), bindAddress=\(mode.bindAddress)"
|
// "bindAddress getter: rawValue='\(rawValue)', mode=\(mode.rawValue),
|
||||||
|
// bindAddress=\(mode.bindAddress)"
|
||||||
// )
|
// )
|
||||||
|
|
||||||
return mode.bindAddress
|
return mode.bindAddress
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ struct AutocompleteTextField: View {
|
||||||
@State private var keyboardNavigating = false
|
@State private var keyboardNavigating = false
|
||||||
|
|
||||||
@State private var textFieldSize: CGSize = .zero
|
@State private var textFieldSize: CGSize = .zero
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TextField(placeholder, text: $text)
|
TextField(placeholder, text: $text)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
@ -266,10 +266,10 @@ struct AutocompleteTextField: View {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Initialize autocompleteService with GitRepositoryMonitor
|
// Initialize autocompleteService with GitRepositoryMonitor
|
||||||
autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
|
autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
|
private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import SwiftUI
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
/// Simple NSWindow-based dropdown for autocomplete
|
/// Simple NSWindow-based dropdown for autocomplete
|
||||||
struct AutocompleteWindowView: NSViewRepresentable {
|
struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
|
|
@ -9,13 +9,13 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
let onSelect: (String) -> Void
|
let onSelect: (String) -> Void
|
||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
@Binding var isShowing: Bool
|
@Binding var isShowing: Bool
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView {
|
func makeNSView(context: Context) -> NSView {
|
||||||
let view = NSView()
|
let view = NSView()
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
if isShowing && !suggestions.isEmpty {
|
if isShowing && !suggestions.isEmpty {
|
||||||
context.coordinator.showDropdown(
|
context.coordinator.showDropdown(
|
||||||
|
|
@ -29,11 +29,11 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
context.coordinator.hideDropdown()
|
context.coordinator.hideDropdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(onSelect: onSelect, isShowing: $isShowing, selectedIndex: $selectedIndex)
|
Coordinator(onSelect: onSelect, isShowing: $isShowing, selectedIndex: $selectedIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class Coordinator: NSObject {
|
class Coordinator: NSObject {
|
||||||
private var dropdownWindow: NSWindow?
|
private var dropdownWindow: NSWindow?
|
||||||
|
|
@ -41,15 +41,15 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
private let onSelect: (String) -> Void
|
private let onSelect: (String) -> Void
|
||||||
@Binding var isShowing: Bool
|
@Binding var isShowing: Bool
|
||||||
@Binding var selectedIndex: Int
|
@Binding var selectedIndex: Int
|
||||||
nonisolated(unsafe) private var clickMonitor: Any?
|
private nonisolated(unsafe) var clickMonitor: Any?
|
||||||
|
|
||||||
init(onSelect: @escaping (String) -> Void, isShowing: Binding<Bool>, selectedIndex: Binding<Int>) {
|
init(onSelect: @escaping (String) -> Void, isShowing: Binding<Bool>, selectedIndex: Binding<Int>) {
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
self._isShowing = isShowing
|
self._isShowing = isShowing
|
||||||
self._selectedIndex = selectedIndex
|
self._selectedIndex = selectedIndex
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
if let monitor = clickMonitor {
|
if let monitor = clickMonitor {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
@ -57,7 +57,7 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func cleanupClickMonitor() {
|
private func cleanupClickMonitor() {
|
||||||
if let monitor = clickMonitor {
|
if let monitor = clickMonitor {
|
||||||
|
|
@ -65,7 +65,7 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
clickMonitor = nil
|
clickMonitor = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func showDropdown(
|
func showDropdown(
|
||||||
on view: NSView,
|
on view: NSView,
|
||||||
|
|
@ -75,7 +75,7 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
width: CGFloat
|
width: CGFloat
|
||||||
) {
|
) {
|
||||||
guard let parentWindow = view.window else { return }
|
guard let parentWindow = view.window else { return }
|
||||||
|
|
||||||
// Create window if needed
|
// Create window if needed
|
||||||
if dropdownWindow == nil {
|
if dropdownWindow == nil {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
|
|
@ -84,23 +84,23 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
|
|
||||||
window.isOpaque = false
|
window.isOpaque = false
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .clear
|
||||||
window.hasShadow = true
|
window.hasShadow = true
|
||||||
window.level = .floating
|
window.level = .floating
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
|
let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
|
||||||
window.contentView = hostingView
|
window.contentView = hostingView
|
||||||
|
|
||||||
self.dropdownWindow = window
|
self.dropdownWindow = window
|
||||||
self.hostingView = hostingView
|
self.hostingView = hostingView
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let window = dropdownWindow,
|
guard let window = dropdownWindow,
|
||||||
let hostingView = hostingView else { return }
|
let hostingView else { return }
|
||||||
|
|
||||||
// Update content with proper binding
|
// Update content with proper binding
|
||||||
let content = VStack(spacing: 0) {
|
let content = VStack(spacing: 0) {
|
||||||
AutocompleteViewWithKeyboard(
|
AutocompleteViewWithKeyboard(
|
||||||
|
|
@ -120,13 +120,13 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
hostingView.rootView = AnyView(content)
|
hostingView.rootView = AnyView(content)
|
||||||
|
|
||||||
// Position window below the text field
|
// Position window below the text field
|
||||||
let viewFrame = view.convert(view.bounds, to: nil)
|
let viewFrame = view.convert(view.bounds, to: nil)
|
||||||
let screenFrame = parentWindow.convertToScreen(viewFrame)
|
let screenFrame = parentWindow.convertToScreen(viewFrame)
|
||||||
|
|
||||||
// Calculate window position
|
// Calculate window position
|
||||||
let windowFrame = NSRect(
|
let windowFrame = NSRect(
|
||||||
x: screenFrame.minX,
|
x: screenFrame.minX,
|
||||||
|
|
@ -134,15 +134,15 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
width: width,
|
width: width,
|
||||||
height: 200
|
height: 200
|
||||||
)
|
)
|
||||||
|
|
||||||
window.setFrame(windowFrame, display: false)
|
window.setFrame(windowFrame, display: false)
|
||||||
|
|
||||||
// Show window
|
// Show window
|
||||||
if window.parent == nil {
|
if window.parent == nil {
|
||||||
parentWindow.addChildWindow(window, ordered: .above)
|
parentWindow.addChildWindow(window, ordered: .above)
|
||||||
}
|
}
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
// Setup click monitoring
|
// Setup click monitoring
|
||||||
if clickMonitor == nil {
|
if clickMonitor == nil {
|
||||||
clickMonitor = NSEvent.addLocalMonitorForEvents(
|
clickMonitor = NSEvent.addLocalMonitorForEvents(
|
||||||
|
|
@ -155,11 +155,11 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func hideDropdown() {
|
func hideDropdown() {
|
||||||
cleanupClickMonitor()
|
cleanupClickMonitor()
|
||||||
|
|
||||||
if let window = dropdownWindow {
|
if let window = dropdownWindow {
|
||||||
if let parent = window.parent {
|
if let parent = window.parent {
|
||||||
parent.removeChildWindow(window)
|
parent.removeChildWindow(window)
|
||||||
|
|
@ -168,4 +168,4 @@ struct AutocompleteWindowView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import UserNotifications
|
||||||
/// across all windows and handles deep linking for terminal session URLs.
|
/// across all windows and handles deep linking for terminal session URLs.
|
||||||
///
|
///
|
||||||
/// This application runs on macOS 14.0+ and requires Swift 6.
|
/// This application runs on macOS 14.0+ and requires Swift 6.
|
||||||
|
/// The app provides terminal access through web browsers.
|
||||||
@main
|
@main
|
||||||
struct VibeTunnelApp: App {
|
struct VibeTunnelApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self)
|
@NSApplicationDelegateAdaptor(AppDelegate.self)
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ child.on('error', (error) => {
|
||||||
// Log when process starts
|
// Log when process starts
|
||||||
child.on('spawn', () => {
|
child.on('spawn', () => {
|
||||||
console.log('Server process spawned successfully');
|
console.log('Server process spawned successfully');
|
||||||
|
console.log(`Server PID: ${child.pid}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle early exit
|
// Handle early exit
|
||||||
|
|
@ -204,6 +205,25 @@ if (process.env.CI || process.env.WAIT_FOR_SERVER) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
console.log('Server is ready, tests can proceed');
|
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
|
}, 3000); // Wait 3 seconds before checking
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// VibeTunnel server entry point
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import type { Response as ExpressResponse } from 'express';
|
import type { Response as ExpressResponse } from 'express';
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,34 @@ export class TestSessionManager {
|
||||||
let sessionId = '';
|
let sessionId = '';
|
||||||
if (!spawnWindow) {
|
if (!spawnWindow) {
|
||||||
console.log(`Web session created, waiting for navigation to session view...`);
|
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();
|
const url = this.page.url();
|
||||||
|
|
||||||
if (!url.includes('/session/')) {
|
if (!url.includes('/session/')) {
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ export class SessionListPage extends BasePage {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
await this.page.goto(`/session/${sessionId}`, {
|
await this.page.goto(`/session/${sessionId}`, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: 15000, // Increase timeout for CI
|
timeout: process.env.CI ? 30000 : 15000, // Increase timeout for CI
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Wait for automatic navigation
|
// Wait for automatic navigation
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,22 @@ test.describe('Terminal Interaction', () => {
|
||||||
// Use unique prefix for this test file to prevent session conflicts
|
// Use unique prefix for this test file to prevent session conflicts
|
||||||
sessionManager = new TestSessionManager(page, 'termint');
|
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
|
// Create a session for all tests using the session manager to ensure proper tracking
|
||||||
const sessionData = await sessionManager.createTrackedSession('terminal-test');
|
const sessionData = await sessionManager.createTrackedSession('terminal-test');
|
||||||
|
|
||||||
// Navigate to the created session
|
// Navigate to the created session with increased timeout for CI
|
||||||
await page.goto(`/session/${sessionData.sessionId}`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`/session/${sessionData.sessionId}`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: process.env.CI ? 30000 : 15000,
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for terminal with proper WebSocket handling
|
// Wait for terminal with proper WebSocket handling
|
||||||
await waitForTerminalReady(page, 10000);
|
await waitForTerminalReady(page, process.env.CI ? 20000 : 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,20 @@ describe('AsciinemaWriter byte position tracking', () => {
|
||||||
it('should track byte position correctly for header', async () => {
|
it('should track byte position correctly for header', async () => {
|
||||||
writer = AsciinemaWriter.create(testFile, 80, 24, 'test command', 'Test Title');
|
writer = AsciinemaWriter.create(testFile, 80, 24, 'test command', 'Test Title');
|
||||||
|
|
||||||
// Give the header time to be written
|
// Wait for the header to be written with a polling mechanism
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
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();
|
const position = writer.getPosition();
|
||||||
expect(position.written).toBeGreaterThan(0);
|
expect(position.written).toBeGreaterThan(0);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue