Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions apps/web/src/cloud/useCloudLinkController.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import type { Discovery } from "@t3tools/client-runtime/relay";
import { EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { act, useLayoutEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({
getToken: vi.fn(),
link: vi.fn(),
unlink: vi.fn(),
preferences: vi.fn(),
refresh: vi.fn(),
refreshRelay: vi.fn(),
toast: vi.fn(),
}));

vi.mock("@clerk/react", () => ({
useAuth: () => ({ getToken: mocks.getToken, isSignedIn: true }),
}));
vi.mock("../components/ui/toast", () => ({ toastManager: { add: mocks.toast } }));
vi.mock("../state/relay", () => ({ relayEnvironmentDiscovery: { refresh: mocks.refreshRelay } }));
vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
vi.mock("./linkEnvironmentAtoms", () => ({
linkPrimaryEnvironment: mocks.link,
unlinkPrimaryEnvironment: mocks.unlink,
updatePrimaryEnvironmentPreferences: mocks.preferences,
}));
vi.mock("./primaryCloudLinkState", () => ({ usePrimaryCloudLinkState: () => linkState }));
vi.mock("../state/environments", () => ({ useRelayEnvironmentDiscovery: () => discovery }));
vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) }));

import { useCloudLinkController } from "./useCloudLinkController";

const target = {
environmentId: EnvironmentId.make("test-environment"),
label: "Test environment",
httpBaseUrl: "http://localhost:1234",
wsBaseUrl: "ws://localhost:1234/ws",
};
const linkState = {
target,
data: { linked: true, managedTunnelActive: true, publishAgentActivity: true },
refresh: mocks.refresh,
error: null,
isPending: false,
};
let discovery: Discovery.RelayEnvironmentDiscoveryState;
let renderer: ReactTestRenderer | undefined;
let controller: ReturnType<typeof useCloudLinkController>;

function Harness() {
const current = useCloudLinkController();
useLayoutEffect(() => {
controller = current;
});
return null;
}

beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.clearAllMocks();
linkState.data = { linked: true, managedTunnelActive: true, publishAgentActivity: true };
discovery = {
environments: new Map([
[
target.environmentId,
{
environment: {
environmentId: target.environmentId,
label: target.label,
endpoint: {
httpBaseUrl: target.httpBaseUrl,
wsBaseUrl: target.wsBaseUrl,
providerKind: "manual",
},
linkedAt: "2026-09-15T12:00:00.000Z",
},
availability: "online",
status: Option.none(),
error: Option.none(),
},
],
]),
refreshing: false,
offline: false,
error: Option.none(),
};
mocks.getToken.mockResolvedValue("test-token");
for (const command of [mocks.link, mocks.unlink, mocks.preferences, mocks.refreshRelay]) {
command.mockResolvedValue(AsyncResult.success(undefined));
}
await act(async () => {
renderer = create(<Harness />);
});
});

afterEach(async () => {
await act(async () => renderer?.unmount());
vi.unstubAllGlobals();
});

it("relinks a publish-only relay record even when local T3 Connect is already on", async () => {
expect(controller.managedTunnelOutOfSync).toBe(true);
await act(async () => {
expect(await controller.reconcileCloudState({ managedTunnel: true, publish: true })).toBe(true);
});
expect(mocks.link).toHaveBeenCalledWith({ target, clerkToken: "test-token", mode: "managed" });
expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: true });
expect(mocks.refreshRelay).toHaveBeenCalledOnce();
});

it("relinks when discovery temporarily clears the drifted record during refresh", async () => {
discovery = { ...discovery, environments: new Map(), refreshing: true };
await act(async () => renderer?.update(<Harness />));
await act(async () => {
expect(await controller.reconcileCloudState({ managedTunnel: true, publish: false })).toBe(
true,
);
});
expect(mocks.link).toHaveBeenCalledWith({ target, clerkToken: "test-token", mode: "managed" });
expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: false });
});

it.each(["healthy", "unknown"])(
"keeps publish changes cheap when the relay mode is %s",
async (state) => {
discovery = {
...discovery,
environments:
state === "unknown"
? new Map()
: new Map(
[...discovery.environments].map(([id, entry]) => [
id,
{
...entry,
environment: {
...entry.environment,
endpoint: { ...entry.environment.endpoint, providerKind: "cloudflare_tunnel" },
},
},
]),
),
};
await act(async () => renderer?.update(<Harness />));
expect(controller.managedTunnelOutOfSync).toBe(false);
await act(async () => {
expect(await controller.reconcileCloudState({ managedTunnel: true, publish: false })).toBe(
true,
);
});
expect(mocks.link).not.toHaveBeenCalled();
expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: false });
},
);

it("allows an explicit repair without discovery and preserves disabled publishing", async () => {
discovery = { ...discovery, environments: new Map() };
linkState.data.publishAgentActivity = false;
await act(async () => renderer?.update(<Harness />));
await act(async () => {
expect(
await controller.reconcileCloudState(
{ managedTunnel: true, publish: false },
{ forceRelink: true },
),
).toBe(true);
});
expect(mocks.link).toHaveBeenCalledWith({ target, clerkToken: "test-token", mode: "managed" });
expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: false });
});

it("keeps publishing on when disabling a managed tunnel", async () => {
await act(async () => {
expect(await controller.reconcileCloudState({ managedTunnel: false, publish: true })).toBe(
true,
);
});
expect(mocks.link).toHaveBeenCalledWith({
target,
clerkToken: "test-token",
mode: "publish_only",
});
expect(mocks.unlink).not.toHaveBeenCalled();
expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: true });
});

it("fully unlinks when both capabilities are off, even with relay drift", async () => {
await act(async () => {
expect(await controller.reconcileCloudState({ managedTunnel: false, publish: false })).toBe(
true,
);
});
expect(mocks.unlink).toHaveBeenCalledWith({ target, clerkToken: "test-token" });
expect(mocks.link).not.toHaveBeenCalled();
});

it("reports a failed repair without changing publishing and refreshes local state", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
mocks.link.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Relay unavailable"))));
try {
await act(async () => {
expect(
await controller.reconcileCloudState(
{ managedTunnel: true, publish: true },
{ forceRelink: true },
),
).toBe(false);
});
expect(controller.operationError).toBe("Relay unavailable");
expect(mocks.preferences).not.toHaveBeenCalled();
expect(mocks.refresh).toHaveBeenCalledOnce();
} finally {
consoleError.mockRestore();
}
});
27 changes: 23 additions & 4 deletions apps/web/src/cloud/useCloudLinkController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { useState } from "react";

import { toastManager } from "../components/ui/toast";
import { useRelayEnvironmentDiscovery } from "../state/environments";
import { relayEnvironmentDiscovery } from "../state/relay";
import { useAtomCommand } from "../state/use-atom-command";
import {
Expand All @@ -29,8 +30,8 @@ export interface CloudLinkDesiredState {
* a single relay link, so consumers express the full desired state and
* `reconcileCloudState` applies it: unlink when neither is wanted, otherwise
* (re)link with the mode the managed-tunnel bit implies and set the publish
* preference. Re-linking only happens when the managed-tunnel mode actually
* changes, so flipping publish alone is cheap.
* preference. Re-linking happens when the mode changes, discovery detects
* drift or is refreshing an unknown mode, or the user requests a repair.
*/
export function useCloudLinkController() {
const { getToken, isSignedIn } = useAuth();
Expand All @@ -48,6 +49,7 @@ export function useCloudLinkController() {
{ reportFailure: false },
);
const primaryCloudLinkState = usePrimaryCloudLinkState();
const relayDiscovery = useRelayEnvironmentDiscovery();
const [operationError, setOperationError] = useState<string | null>(null);

const reportUpdateFailure = (cause: unknown) => {
Expand Down Expand Up @@ -76,8 +78,18 @@ export function useCloudLinkController() {
primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
const linked = primaryCloudLinkState.data?.linked ?? false;
const relayEnvironment = primaryCloudLinkState.target
? relayDiscovery.environments.get(primaryCloudLinkState.target.environmentId)?.environment
: undefined;
const managedTunnelOutOfSync =
linked &&
relayEnvironment !== undefined &&
managedTunnelActive !== (relayEnvironment.endpoint.providerKind === "cloudflare_tunnel");

const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
const reconcileCloudState = async (
desired: CloudLinkDesiredState,
{ forceRelink = false }: { readonly forceRelink?: boolean } = {},
): Promise<boolean> => {
setOperationError(null);
const target = primaryCloudLinkState.target;
if (!target) {
Expand Down Expand Up @@ -115,7 +127,13 @@ export function useCloudLinkController() {
reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
return false;
}
if (!linked || managedTunnelActive !== desired.managedTunnel) {
if (
forceRelink ||
!linked ||
(relayDiscovery.refreshing && relayEnvironment === undefined) ||
managedTunnelOutOfSync ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High cloud/useCloudLinkController.ts:133

Changing Publish activity during discovery refresh reports success without relinking a drifted environment, leaving the relay publish-only even though local state still says managed. Discovery clears environments, making managedTunnelOutOfSync false; include relayDiscovery.refreshing in the relink condition (or disable these controls while refreshing) so the managed mode is restored.

-        managedTunnelOutOfSync ||
+        relayDiscovery.refreshing ||
+        managedTunnelOutOfSync ||
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/cloud/useCloudLinkController.ts around line 133:

Changing Publish activity during discovery refresh reports success without relinking a drifted environment, leaving the relay `publish-only` even though local state still says managed. Discovery clears `environments`, making `managedTunnelOutOfSync` false; include `relayDiscovery.refreshing` in the relink condition (or disable these controls while refreshing) so the managed mode is restored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 7db4055. Reconciliation now relinks when refresh has temporarily cleared the environment record. A known matching record still avoids unnecessary relinking while status probes finish. The new regression failed before the fix and now passes; all 13 affected tests, targeted lint/formatting, and web typecheck pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

managedTunnelActive !== desired.managedTunnel
) {
const linkResult = await linkPrimaryEnvironment({
target,
clerkToken,
Expand Down Expand Up @@ -156,6 +174,7 @@ export function useCloudLinkController() {
linkState: primaryCloudLinkState,
linked,
managedTunnelActive,
managedTunnelOutOfSync,
publishAgentActivity,
operationError,
reconcileCloudState,
Expand Down
49 changes: 36 additions & 13 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1660,7 +1660,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
const {
isSignedIn,
linkState: primaryCloudLinkState,
linked,
managedTunnelActive,
managedTunnelOutOfSync,
publishAgentActivity,
operationError,
reconcileCloudState,
Expand All @@ -1674,10 +1676,14 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
? "Your session does not have permission to manage T3 Connect access."
: null;
const isBusy = isUpdating || isUpdatingPreference;
const isDisabled = !canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy;

const updateManagedTunnel = async (enabled: boolean) => {
const updateManagedTunnel = async (enabled: boolean, forceRelink = false) => {
setIsUpdating(true);
const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
const ok = await reconcileCloudState(
{ managedTunnel: enabled, publish: publishAgentActivity },
{ forceRelink },
);
if (ok) {
// Turning the tunnel off while publishing stays on downgrades the link
// rather than removing it — say so instead of claiming an unlink.
Expand All @@ -1691,7 +1697,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
description: enabled
? "This environment is available through T3 Connect."
: publishAgentActivity
? "The managed tunnel was removed. Agent activity publishing stays on."
? "This link now publishes activity only. Turn off Publish agent activity to fully unlink it."
: "This environment is no longer available through T3 Connect.",
});
}
Expand Down Expand Up @@ -1719,18 +1725,35 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
<SettingsRow
title={searchableSetting("t3-connect").title}
description={
managedTunnelActive
? "This environment is available to your other devices through T3 Connect."
: "Make this environment available to your other devices through T3 Connect."
managedTunnelOutOfSync
? "T3 Connect is out of sync. Repair the link to reconnect your devices."
: managedTunnelActive
? "This environment is available to your other devices through T3 Connect."
: publishAgentActivity
? "Activity publishing only. Turn off Publish agent activity to fully unlink this environment."
: "Make this environment available to your other devices through T3 Connect."
}
status={operationError ?? primaryCloudLinkState.error}
control={
<CloudLinkSwitch
checked={managedTunnelActive}
disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}
disabledReason={disabledReason}
onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
/>
<div className="flex items-center gap-3">
{linked ? (
<Button
size="sm"
variant="outline"
aria-label="Repair T3 Connect"
disabled={isDisabled}
onClick={() => void updateManagedTunnel(true, true)}
>
Repair
</Button>
) : null}
<CloudLinkSwitch
checked={managedTunnelActive}
disabled={isDisabled}
disabledReason={disabledReason}
onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
/>
</div>
}
/>
) : null}
Expand All @@ -1741,7 +1764,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
<CloudLinkSwitch
ariaLabel="Publish agent activity to mobile clients"
checked={publishAgentActivity}
disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}
disabled={isDisabled}
disabledReason={disabledReason}
onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
/>
Expand Down
Loading
Loading