diff --git a/frontend/src/components/workspace/channels/workspace-channels-list.tsx b/frontend/src/components/workspace/channels/workspace-channels-list.tsx index 2a0a8675d..70c4dc3d5 100644 --- a/frontend/src/components/workspace/channels/workspace-channels-list.tsx +++ b/frontend/src/components/workspace/channels/workspace-channels-list.tsx @@ -15,19 +15,17 @@ import { useChannelProviders, useConnectChannelProvider, } from "@/core/channels/hooks"; +import { + closeConnectWindow, + openConnectUrl, + prepareConnectWindow, +} from "@/core/channels/open-connect-url"; import type { ChannelProvider } from "@/core/channels/types"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; import { ChannelProviderIcon } from "./channel-provider-icon"; -function openConnectUrl(url: string) { - const opened = window.open(url, "_blank", "noopener,noreferrer"); - if (!opened) { - window.location.assign(url); - } -} - function providerCanConnect(provider: ChannelProvider): boolean { return ( provider.enabled && @@ -97,8 +95,11 @@ export function WorkspaceChannelsList() { !provider.configured ? t.channels.unconfigured : undefined } onClick={() => { + const connectWindow = prepareConnectWindow(); connectMutation.mutate(provider.provider, { - onSuccess: (result) => openConnectUrl(result.url), + onSuccess: (result) => + openConnectUrl(result.url, connectWindow), + onError: () => closeConnectWindow(connectWindow), }); }} > diff --git a/frontend/src/components/workspace/settings/channels-settings-page.tsx b/frontend/src/components/workspace/settings/channels-settings-page.tsx index 113619c8d..2503486c5 100644 --- a/frontend/src/components/workspace/settings/channels-settings-page.tsx +++ b/frontend/src/components/workspace/settings/channels-settings-page.tsx @@ -24,6 +24,11 @@ import { useConnectChannelProvider, useDisconnectChannelConnection, } from "@/core/channels/hooks"; +import { + closeConnectWindow, + openConnectUrl, + prepareConnectWindow, +} from "@/core/channels/open-connect-url"; import type { ChannelConnection, ChannelProvider } from "@/core/channels/types"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; @@ -32,13 +37,6 @@ import { ChannelProviderIcon } from "../channels/channel-provider-icon"; import { SettingsSection } from "./settings-section"; -function openConnectUrl(url: string) { - const opened = window.open(url, "_blank", "noopener,noreferrer"); - if (!opened) { - window.location.assign(url); - } -} - function getProviderDescription( provider: ChannelProvider, descriptions: Record, @@ -144,8 +142,11 @@ function ChannelProviderItem({ disabled={!canConnect || isConnecting} title={!provider.configured ? t.channels.unconfigured : undefined} onClick={() => { + const connectWindow = prepareConnectWindow(); connectMutation.mutate(provider.provider, { - onSuccess: (result) => openConnectUrl(result.url), + onSuccess: (result) => + openConnectUrl(result.url, connectWindow), + onError: () => closeConnectWindow(connectWindow), }); }} > diff --git a/frontend/src/core/channels/open-connect-url.ts b/frontend/src/core/channels/open-connect-url.ts new file mode 100644 index 000000000..75d31b95c --- /dev/null +++ b/frontend/src/core/channels/open-connect-url.ts @@ -0,0 +1,27 @@ +export type ChannelConnectWindow = Window | null; + +export function prepareConnectWindow(): ChannelConnectWindow { + const opened = window.open("about:blank", "_blank"); + if (opened) { + opened.opener = null; + } + return opened; +} + +export function openConnectUrl( + url: string, + connectWindow: ChannelConnectWindow = prepareConnectWindow(), +) { + if (connectWindow && !connectWindow.closed) { + connectWindow.location.replace(url); + return; + } + + window.location.assign(url); +} + +export function closeConnectWindow(connectWindow: ChannelConnectWindow) { + if (connectWindow && !connectWindow.closed) { + connectWindow.close(); + } +} diff --git a/frontend/tests/e2e/channels.spec.ts b/frontend/tests/e2e/channels.spec.ts index fd8c41185..69486969c 100644 --- a/frontend/tests/e2e/channels.spec.ts +++ b/frontend/tests/e2e/channels.spec.ts @@ -46,6 +46,19 @@ function mockChannelsAPI(page: Page) { body: JSON.stringify({ connections: [] }), }); }); + + void page.route("**/api/channels/slack/connect", (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + provider: "slack", + mode: "oauth", + url: "http://localhost:3000/mock-slack-oauth?client_id=dev&state=test", + expires_in: 600, + }), + }); + }); } test.describe("IM channels", () => { @@ -73,5 +86,16 @@ test.describe("IM channels", () => { await expect(page.getByText("Telegram direct messages")).toBeVisible(); await expect(page.getByText("Slack workspace messages")).toBeVisible(); await expect(page.getByText("Discord server messages")).toBeVisible(); + + const dialog = page.getByRole("dialog", { name: "Settings" }); + const connectButtons = dialog.getByRole("button", { name: "Connect" }); + await expect(connectButtons).toHaveCount(3); + + const popupPromise = page.waitForEvent("popup"); + await connectButtons.nth(1).click(); + const popup = await popupPromise; + await expect(page).toHaveURL(/\/workspace\/chats\/new/); + await expect(popup).toHaveURL(/\/mock-slack-oauth/); + await popup.close(); }); }); diff --git a/frontend/tests/unit/core/channels/open-connect-url.test.ts b/frontend/tests/unit/core/channels/open-connect-url.test.ts new file mode 100644 index 000000000..bfa50adca --- /dev/null +++ b/frontend/tests/unit/core/channels/open-connect-url.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + closeConnectWindow, + openConnectUrl, + prepareConnectWindow, +} from "@/core/channels/open-connect-url"; + +type PopupStub = { + closed: boolean; + close: ReturnType; + location: { + replace: ReturnType; + }; + opener: unknown; +}; + +function stubWindow(openResult: PopupStub | null) { + const assign = vi.fn(); + const open = vi.fn(() => openResult); + vi.stubGlobal("window", { + open, + location: { assign }, + }); + return { assign, open }; +} + +function makePopup(): PopupStub { + return { + closed: false, + close: vi.fn(), + location: { replace: vi.fn() }, + opener: {}, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("channel connect window helpers", () => { + test("opens a blank tab synchronously and detaches opener", () => { + const popup = makePopup(); + const { open } = stubWindow(popup); + + const prepared = prepareConnectWindow(); + + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(prepared).toBe(popup); + expect(popup.opener).toBeNull(); + }); + + test("navigates a prepared popup without opening another window", () => { + const popup = makePopup(); + const { assign, open } = stubWindow(null); + + openConnectUrl( + "https://t.me/deerflow_bot?start=state", + popup as unknown as Window, + ); + + expect(open).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + expect(popup.location.replace).toHaveBeenCalledWith( + "https://t.me/deerflow_bot?start=state", + ); + }); + + test("falls back to current-window navigation when no popup is available", () => { + const { assign } = stubWindow(null); + + openConnectUrl("https://slack.com/oauth/v2/authorize"); + + expect(assign).toHaveBeenCalledWith("https://slack.com/oauth/v2/authorize"); + }); + + test("closes a prepared popup on connect failure", () => { + const popup = makePopup(); + + closeConnectWindow(popup as unknown as Window); + + expect(popup.close).toHaveBeenCalled(); + }); +}); diff --git a/scripts/serve.sh b/scripts/serve.sh index aac39bfa4..478fc1096 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -29,14 +29,6 @@ set -e REPO_ROOT="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd -P)" cd "$REPO_ROOT" -# ── Load .env ──────────────────────────────────────────────────────────────── - -if [ -f "$REPO_ROOT/.env" ]; then - set -a - source "$REPO_ROOT/.env" - set +a -fi - _pick_python() { local candidate for candidate in python3 python py; do @@ -48,6 +40,61 @@ _pick_python() { return 1 } +_load_dotenv_file() { + local env_file=$1 + local python_bin + + [ -f "$env_file" ] || return 0 + + if ! python_bin="$(_pick_python)"; then + echo "Python is required to load $env_file safely." + exit 1 + fi + + eval "$("$python_bin" - "$env_file" <<'PY' +import re +import shlex +import sys +from pathlib import Path + +env_path = Path(sys.argv[1]) +assign_re = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$") + + +def strip_unquoted_comment(value: str) -> str: + for index, char in enumerate(value): + if char == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value + + +for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + match = assign_re.match(line) + if not match: + continue + + key, value = match.groups() + value = value.strip() + try: + parsed = shlex.split(value, comments=True, posix=True) + except ValueError: + value = strip_unquoted_comment(value) + else: + value = parsed[0] if parsed else "" + + print(f"export {key}={shlex.quote(value)}") +PY +)" +} + +# ── Load .env ──────────────────────────────────────────────────────────────── + +_load_dotenv_file "$REPO_ROOT/.env" + # ── Argument parsing ───────────────────────────────────────────────────────── DEV_MODE=true @@ -179,7 +226,10 @@ _is_port_listening() { fi if command -v netstat >/dev/null 2>&1; then - if netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|[.:])${port}$"; then + if netstat -ltn 2>/dev/null | awk -v port="$port" ' + toupper($NF) == "LISTEN" && $4 ~ "(^|[.:])" port "$" { found = 1 } + END { exit found ? 0 : 1 } + '; then return 0 fi fi @@ -187,6 +237,21 @@ _is_port_listening() { return 1 } +_wait_for_port_free() { + local port=$1 + local timeout=${2:-10} + local elapsed=0 + + while _is_port_listening "$port"; do + if [ "$elapsed" -ge "$timeout" ]; then + echo " ⚠ Port $port is still in use after ${timeout}s" + return 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done +} + _is_repo_nginx_pid() { local pid=$1 local command @@ -239,9 +304,12 @@ stop_all() { echo "Stopping all services..." _report_reclaimed_ports _kill_repo_processes "uvicorn app.gateway.app:app" + _kill_repo_processes "pnpm .*run dev" _kill_repo_processes "next dev" _kill_repo_processes "next start" _kill_repo_processes "next-server" + _kill_repo_processes "next/dist" + _kill_repo_processes "turbopack" nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit 2>/dev/null || true sleep 1 _kill_repo_nginx @@ -252,6 +320,9 @@ stop_all() { _kill_repo_port 8001 _kill_repo_port 3000 _kill_repo_port 2026 + _wait_for_port_free 8001 30 || true + _wait_for_port_free 3000 30 || true + _wait_for_port_free 2026 30 || true ./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true echo "✓ All services stopped" } @@ -414,6 +485,7 @@ trap 'cleanup 143' TERM # In daemon mode, wraps with nohup. Waits for port to be ready. run_service() { local name="$1" cmd="$2" port="$3" timeout="$4" + local service_pid if _is_port_listening "$port"; then echo "✗ $name cannot start because port $port is already in use." @@ -427,6 +499,7 @@ run_service() { else sh -c "$cmd" & fi + service_pid=$! ./scripts/wait-for-port.sh "$port" "$timeout" "$name" || { local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log" @@ -434,6 +507,12 @@ run_service() { [ -f "$logfile" ] && tail -20 "$logfile" cleanup 1 } + if ! kill -0 "$service_pid" 2>/dev/null; then + local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log" + echo "✗ $name process exited after port $port became available." + [ -f "$logfile" ] && tail -20 "$logfile" + cleanup 1 + fi echo "✓ $name started on localhost:$port" }