Skip to content
Merged
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
8 changes: 6 additions & 2 deletions src/components/hosts/TeamSessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ const { teamState, sessionState, uiState, getMyUserId, accessibleVaultIds } = h;

import { TeamSessions } from "./TeamSessions";

const SESSION_ID = "8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90";

const active = (o: Partial<{
id: string; connection_name: string; host_user_id: string;
participant_count: number; participants: { user_id: string; handle: string }[]; vault_ids: string[];
Expand Down Expand Up @@ -178,10 +180,12 @@ test("valid code calls joinSession with sessionId + token", async () => {
render(<TeamSessions />);
fireEvent.click(screen.getByText("hosts.teamSessions.joinByCode"));
const input = screen.getByPlaceholderText("hosts.teamSessions.inviteCodePlaceholder");
fireEvent.change(input, { target: { value: "sess-9:tok-9" } });
// A real session id: the field now rejects shapes that only look like one, so
// `host:22` and friends can no longer reach the join call.
fireEvent.change(input, { target: { value: `${SESSION_ID}:tok-9` } });
fireEvent.click(screen.getByText("hosts.teamSessions.join"));
await waitFor(() =>
expect(teamState.joinSession).toHaveBeenCalledWith("sess-9", expect.any(Function), "tok-9"),
expect(teamState.joinSession).toHaveBeenCalledWith(SESSION_ID, expect.any(Function), "tok-9"),
);
});

Expand Down
11 changes: 6 additions & 5 deletions src/components/hosts/TeamSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useAccessibleVaultIds } from "@/hooks/useAccessibleVaultIds";
import { AvatarStack } from "@/components/shared/AvatarStack";
import { AvatarTile } from "@/components/shared/AvatarTile";
import { BaseCard } from "@/components/shared/BaseCard";
import { parseInviteCode } from "@/services/inviteCode";
import { isJoinInput, resolveJoinInput } from "@/services/resolveJoinInput";
import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin";
import { sessionDisplayName } from "@/services/teamSharing";

Expand Down Expand Up @@ -110,17 +110,18 @@ export function TeamSessions() {
const code = inviteCode.trim();
if (!code) return;

const parsed = parseInviteCode(code);
if (!parsed) {
if (!isJoinInput(code)) {
setJoinError(t("hosts.teamSessions.invalidCodeFormat"));
return;
}
const { sessionId, token } = parsed;

setJoinLoading(true);
setJoinError(null);
try {
await doJoinSession(sessionId, token);
// A short code is exchanged for a session and a secret here; the other two
// shapes already carry theirs.
const { sessionId, inviteToken } = await resolveJoinInput(code);
await doJoinSession(sessionId, inviteToken);
setShowJoinModal(false);
} catch (err) {
setJoinError(err instanceof Error ? err.message : t("hosts.teamSessions.failedToJoinSession"));
Expand Down
27 changes: 12 additions & 15 deletions src/components/omni/OmniSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { useToggleSettings } from "@/hooks/useToggleSettings";
import { parseQuickConnect, type QuickConnectIntent } from "@/services/quickConnect";
import { launchHost, launchQuickConnect, launchLocalShell } from "@/services/launch";
import { sessionDisplayName } from "@/services/teamSharing";
import { isInviteCode, parseInviteCode } from "@/services/inviteCode";
import { isJoinInput, resolveJoinInput } from "@/services/resolveJoinInput";
import { computeSectionBoundaries } from "./omniSections";
import {
selectRecentHosts,
Expand Down Expand Up @@ -226,7 +226,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
}
if (category === "marketplace") return [];
if (category === "join") {
if (isInviteCode(q)) {
if (isJoinInput(q)) {
return [{ kind: "join-code", id: "", label: "", icon: "", code: query.trim() }];
}
const sessionItems = teamSessions
Expand All @@ -238,7 +238,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
const result: OmniItem[] = [];

// A valid invite code cannot also be a host, so it outranks quick-connect.
if (isInviteCode(query)) {
if (isJoinInput(query)) {
result.push({ kind: "join-code", id: "", label: "", icon: "", code: query.trim() });
}

Expand Down Expand Up @@ -494,18 +494,15 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
}
}, 0);
} else if (item.kind === "join-code") {
const parsed = parseInviteCode(item.code);
if (parsed) {
const { sessionId, token } = parsed;
(async () => {
await joinTeamSessionAndOpenTab({
sessionId,
connectionName: "Shared Terminal",
inviteToken: token,
});
setSidebarOpen(false);
})().catch(console.error);
}
(async () => {
const { sessionId, inviteToken } = await resolveJoinInput(item.code);
await joinTeamSessionAndOpenTab({
sessionId,
connectionName: "Shared Terminal",
inviteToken,
});
setSidebarOpen(false);
})().catch(console.error);
onClose();
} else if (item.kind === "local-shell") {
launchLocalShell(item.shell?.path);
Expand Down
15 changes: 12 additions & 3 deletions src/components/terminal/ShareMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { uninviteFromSession } from "@/services/teamService";
import { guestCapFor, highestOwnerTier, inviteSessionOf, membersOfTeams, seatUsage, type InviteSession, type InviteTarget, type ShareTier } from "@/services/teamSharing";
import { useDelayedUnmount } from "@/hooks/useDelayedUnmount";
import { InviteCodeField } from "./InviteCodeField";
import { SpokenCodeRow } from "./SpokenCodeRow";
import { PeopleTab } from "./PeopleTab";
import { ParticipantsRatioNotice } from "./ParticipantsRatioNotice";

Expand Down Expand Up @@ -59,6 +60,10 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio
const activeMp = mpConnections[activeSessionId];
const isSharing = !!activeMp && !activeMp.ended;

// The store's copy outlives this menu's local state, so reopening a sharing
// session shows the link it already has instead of an empty tab.
const linkToken = activeMp?.inviteToken ?? inviteLinkToken;

// The server's record of this session, if one exists yet — the source of truth for
// vault scope and per-invitee grants (#66). Empty until this local session has a
// multiplayer counterpart the server has told us about.
Expand Down Expand Up @@ -313,7 +318,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio
connectionName={connectionName}
loading={loading}
guestCap={guestCap}
inviteLinkToken={inviteLinkToken}
inviteLinkToken={linkToken}
autoCopied={autoCopied}
tier={tier}
inviteSession={inviteSession}
Expand Down Expand Up @@ -394,7 +399,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio
) : (
<InviteLinkTab
loading={loading}
inviteLinkToken={inviteLinkToken}
inviteLinkToken={linkToken}
sessionId={activeMp?.multiplayerSessionId ?? ""}
autoCopied={autoCopied}
guestCap={guestCap}
Expand Down Expand Up @@ -497,8 +502,9 @@ function ActiveSharingView({
)}

{inviteLinkToken && (
<div className="mb-3">
<div className="mb-3 flex flex-col gap-2">
<InviteCodeField code={buildInviteLink(activeMp.multiplayerSessionId, inviteLinkToken)} autoCopied={autoCopied} />
<SpokenCodeRow sessionId={activeMp.multiplayerSessionId} />
</div>
)}

Expand Down Expand Up @@ -673,6 +679,9 @@ function InviteLinkTab({
{t("terminal.share.shareCodeDescription")}
</p>
<InviteCodeField code={buildInviteLink(sessionId, inviteLinkToken)} autoCopied={autoCopied} />
<div className="mt-2">
<SpokenCodeRow sessionId={sessionId} />
</div>
</>
) : (
<>
Expand Down
163 changes: 163 additions & 0 deletions src/components/terminal/SpokenCodeRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { test, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react";

vi.mock("react-i18next", () => ({
// Interpolates, because the countdown assertions read the substituted value.
useTranslation: () => ({
t: (k: string, opts?: Record<string, unknown>) => (opts?.time ? `${k} ${opts.time}` : k),
}),
}));
vi.mock("@iconify/react", () => ({ Icon: () => null }));

const writeClipboard = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("@/utils/clipboard", () => ({ writeClipboard }));

const mintSessionCode = vi.hoisted(() => vi.fn());
vi.mock("@/services/multiplayerService", () => ({ mintSessionCode }));

import { SpokenCodeRow } from "./SpokenCodeRow";

function mintsIn(seconds: number, code = "K7M2-P9QX-3B") {
mintSessionCode.mockResolvedValue({
code,
expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
});
}

const T0 = Date.parse("2026-08-17T09:00:00.000Z");

/**
* Fakes the clock the component reads along with the timers it schedules, and
* advances only when told to. `shouldAdvanceTime` must stay off: combined with a
* fixed expiry it let real wall time leak into the arithmetic, so the first tick
* read 0:00 once real UTC passed the fixture's expiry.
*/
function withControlledClock(startMs = T0) {
vi.useFakeTimers({
toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"],
});
vi.setSystemTime(startMs);
return {
async advance(ms: number) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
},
};
}

/** Flushes the mint promise without waitFor, which would poll on faked timers. */
async function mintWithFakeTimers() {
fireEvent.click(screen.getByRole("button"));
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
}

function expiringAt(offsetMs: number, code = "K7M2-P9QX-3B") {
mintSessionCode.mockResolvedValue({
code,
expiresAt: new Date(T0 + offsetMs).toISOString(),
});
}

async function mint() {
fireEvent.click(screen.getByRole("button"));
await waitFor(() => expect(screen.getByRole("textbox")).toBeTruthy());
}

beforeEach(() => {
writeClipboard.mockClear();
mintSessionCode.mockReset();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});

test("mints only when asked, so opening the tab does not spend a code", () => {
mintsIn(600);
render(<SpokenCodeRow sessionId="sess-1" />);

expect(mintSessionCode).not.toHaveBeenCalled();
expect(screen.queryByRole("textbox")).toBeNull();
});

test("shows the minted code and copies exactly what it displays", async () => {
mintsIn(600);
render(<SpokenCodeRow sessionId="sess-1" />);
await mint();

const field = screen.getByRole("textbox") as HTMLInputElement;
expect(field.value).toBe("K7M2-P9QX-3B");
expect(mintSessionCode).toHaveBeenCalledWith("sess-1");

fireEvent.click(screen.getByText("common.action.copy"));
await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith("K7M2-P9QX-3B"));
});

test("groups a code the server sent unformatted", async () => {
mintsIn(600, "K7M2P9QX3B");
render(<SpokenCodeRow sessionId="sess-1" />);
await mint();

expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("K7M2-P9QX-3B");
});

test("counts down toward expiry", async () => {
const clock = withControlledClock();
expiringAt(600_000);
render(<SpokenCodeRow sessionId="sess-1" />);
await mintWithFakeTimers();

expect(screen.getByText(/expiresIn 10:00/)).toBeTruthy();
await clock.advance(62_000);
expect(screen.getByText(/expiresIn 8:58/)).toBeTruthy();
});

// An expired code is worse than no code: it looks usable and fails at the guest's end.
test("drops an expired code and offers a fresh one", async () => {
const clock = withControlledClock();
expiringAt(5_000);
render(<SpokenCodeRow sessionId="sess-1" />);
await mintWithFakeTimers();

await clock.advance(6_000);

expect(screen.queryByRole("textbox")).toBeNull();
expect(screen.getByText("terminal.share.codeExpired")).toBeTruthy();
});

test("regenerating replaces the code, matching the server revoking the old one", async () => {
mintsIn(600, "K7M2-P9QX-3B");
render(<SpokenCodeRow sessionId="sess-1" />);
await mint();

mintsIn(600, "AAAA-BBBB-CC");
fireEvent.click(screen.getByText("terminal.share.newCode"));

await waitFor(() =>
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("AAAA-BBBB-CC"),
);
});

test("surfaces a mint failure instead of showing a stale code", async () => {
mintSessionCode.mockRejectedValue(new Error("boom"));
render(<SpokenCodeRow sessionId="sess-1" />);

fireEvent.click(screen.getByRole("button"));

await waitFor(() => expect(screen.getByText("boom")).toBeTruthy());
expect(screen.queryByRole("textbox")).toBeNull();
});

// The interval must die with the component; a leaked one ticks against an unmounted
// tree for the rest of the session (the leak PR #128 had to fix).
test("clears its countdown on unmount", async () => {
withControlledClock();
expiringAt(600_000);
const view = render(<SpokenCodeRow sessionId="sess-1" />);
await mintWithFakeTimers();

const clearSpy = vi.spyOn(globalThis, "clearInterval");
view.unmount();

expect(clearSpy).toHaveBeenCalled();
});
Loading