Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 5 additions & 1 deletion src/__tests__/integration/network-scan-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,13 @@ describe("NetworkScanModal", () => {
);
});

// Assert - mDNS hostname is preferred and default port is hidden
// Assert - show both stable-looking hostname and concrete route so the
// user can verify which host the app is about to contact.
await waitFor(() => {
expect(screen.getByText("test-device.local")).toBeInTheDocument();
expect(
screen.getByText("settings.networkScan.addresses"),
).toBeInTheDocument();
});
});

Expand Down
136 changes: 124 additions & 12 deletions src/__tests__/unit/components/ConnectionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { NotificationRequest } from "@/lib/coreApi";
import { ClientCapability, InboxSeverity, Notification } from "@/lib/models";
import { logger } from "@/lib/logger";
import { invalidateLibraryImageCache } from "@/lib/libraryImageCache";
import { reconcileCredentialProvenAliases } from "@/lib/devices/credentialProof";

// Capture event handlers for notification testing
let capturedEventHandlers: {
Expand All @@ -54,6 +55,10 @@ vi.mock("@/lib/libraryImageCache", () => ({
invalidateLibraryImageCache: vi.fn(),
}));

vi.mock("@/lib/devices/credentialProof", () => ({
reconcileCredentialProvenAliases: vi.fn().mockResolvedValue([]),
}));

vi.mock("../../../lib/transport", () => {
const mockTransport = {
deviceId: "test-device",
Expand Down Expand Up @@ -431,18 +436,20 @@ describe("ConnectionProvider", () => {
...overrides,
});

async function seedMdnsDevice() {
await seedDeviceRegistry(
[
mockDeviceRecord({
recordId: MDNS_RECORD_ID,
address: "steamdeck.local",
source: "mdns",
discoveryId: "core-id",
}),
],
MDNS_RECORD_ID,
);
async function seedMdnsDevice(resolvedAddresses?: string[]) {
const record = mockDeviceRecord({
recordId: MDNS_RECORD_ID,
address: "steamdeck.local",
source: "mdns",
discoveryId: "core-id",
});
if (resolvedAddresses) {
record.endpoints = record.endpoints.map((endpoint) => ({
...endpoint,
resolvedAddresses,
}));
}
await seedDeviceRegistry([record], MDNS_RECORD_ID);
}

beforeEach(async () => {
Expand Down Expand Up @@ -509,6 +516,59 @@ describe("ConnectionProvider", () => {
expect(deviceRegistry.activeEndpoint()?.address).toBe("steamdeck.local");
});

it("should pass every resolved address to the transport", async () => {
render(
<ConnectionProvider>
<div>Test</div>
</ConnectionProvider>,
);

await waitFor(() => {
expect(ZeroConf.watch).toHaveBeenCalled();
});
act(() => {
__simulateDeviceDiscovered(
advertise({ ipv4Addresses: ["10.0.0.206", "10.0.0.218"] }),
);
});

await waitFor(() => {
expect(connectionManager.addDevice).toHaveBeenCalledWith(
expect.objectContaining({
address: "ws://10.0.0.206:7497/api/v0.1",
fallbackAddresses: ["ws://10.0.0.218:7497/api/v0.1"],
}),
);
});
});

it("should retry after mDNS confirms an already-known route", async () => {
await seedMdnsDevice(["10.0.0.206"]);

render(
<ConnectionProvider>
<div>Test</div>
</ConnectionProvider>,
);

await waitFor(() => {
expect(connectionManager.addDevice).toHaveBeenCalledWith(
expect.objectContaining({ address: "ws://10.0.0.206:7497/api/v0.1" }),
);
});
vi.mocked(connectionManager.immediateReconnectActive).mockClear();

act(() => {
__simulateDeviceDiscovered(advertise());
});

await waitFor(() => {
expect(
connectionManager.immediateReconnectActive,
).toHaveBeenCalledTimes(1);
});
});

it("should follow the hostname to a new address", async () => {
render(
<ConnectionProvider>
Expand Down Expand Up @@ -633,6 +693,53 @@ describe("ConnectionProvider", () => {
});
});

it("should retry immediately when a resolved mDNS connection drops", async () => {
render(
<ConnectionProvider>
<div>Test</div>
</ConnectionProvider>,
);

await waitFor(() => {
expect(ZeroConf.watch).toHaveBeenCalled();
});
act(() => {
__simulateDeviceDiscovered(advertise());
});
await waitFor(() => {
expect(connectionManager.addDevice).toHaveBeenCalledWith(
expect.objectContaining({ address: "ws://10.0.0.206:7497/api/v0.1" }),
);
});

act(() => {
capturedEventHandlers.onConnectionChange!(MDNS_RECORD_ID, {
state: "connected",
hasData: true,
hasConnectedBefore: true,
});
});
await waitFor(() => {
expect(ZeroConf.unwatch).toHaveBeenCalled();
});
vi.mocked(connectionManager.immediateReconnectActive).mockClear();

act(() => {
capturedEventHandlers.onConnectionChange!(MDNS_RECORD_ID, {
state: "reconnecting",
hasData: true,
hasConnectedBefore: true,
});
});

await waitFor(() => {
expect(ZeroConf.watch).toHaveBeenCalledTimes(2);
expect(
connectionManager.immediateReconnectActive,
).toHaveBeenCalledTimes(1);
});
});

it("should keep browsing while the connection is still down", async () => {
render(
<ConnectionProvider>
Expand Down Expand Up @@ -2863,6 +2970,7 @@ describe("connection event handling", () => {
const record = deviceRegistry.getSnapshot().records[RECORD_ID];
expect(record?.lastConnectedAt).toBeGreaterThanOrEqual(before);
});
expect(reconcileCredentialProvenAliases).not.toHaveBeenCalled();
});

it("should move a pre-V2 pairing onto the record once the peer authenticates with it", async () => {
Expand Down Expand Up @@ -2910,6 +3018,10 @@ describe("connection event handling", () => {
expect(
deviceRegistry.getSnapshot().records[RECORD_ID]?.legacyCredentialKey,
).toBeUndefined();
expect(reconcileCredentialProvenAliases).toHaveBeenCalledWith(
RECORD_ID,
expect.objectContaining({ authToken: "token-abc" }),
);
});
});

Expand Down
68 changes: 40 additions & 28 deletions src/__tests__/unit/components/wui/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { render, screen, fireEvent, act } from "../../../../test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Button } from "@/components/wui/Button";

// Mock useHaptics hook
// Mock touch-only haptic press handling
const mockImpact = vi.fn();
vi.mock("@/hooks/useHaptics", () => ({
useHaptics: () => ({
impact: mockImpact,
}),
vi.mock("@/hooks/useHapticPress", () => ({
useHapticPress:
(style: string, enabled: boolean) => (event: { pointerType: string }) => {
if (enabled && event.pointerType === "touch") {
mockImpact(style);
}
},
}));

describe("Button", () => {
Expand Down Expand Up @@ -111,46 +114,55 @@ describe("Button", () => {
});

describe("haptic feedback", () => {
it("should trigger light haptic feedback on touch start for default intent", () => {
render(<Button label="Test button" />);
it("should trigger light haptic feedback when a default press completes", () => {
render(<Button label="Test button" onClick={() => {}} />);

const button = screen.getByRole("button");
fireEvent.touchStart(button, {
touches: [{ clientX: 0, clientY: 0 }],
});
fireEvent.pointerUp(screen.getByRole("button"), { pointerType: "touch" });

expect(mockImpact).toHaveBeenCalledWith("light");
});

it("should trigger medium haptic feedback on touch start for primary intent", () => {
render(<Button label="Test button" intent="primary" />);
it("should trigger medium haptic feedback when a primary press completes", () => {
render(
<Button label="Test button" intent="primary" onClick={() => {}} />,
);

const button = screen.getByRole("button", { name: "Test button" });
fireEvent.touchStart(button, {
touches: [{ clientX: 0, clientY: 0 }],
});
fireEvent.pointerUp(screen.getByRole("button"), { pointerType: "touch" });

expect(mockImpact).toHaveBeenCalledWith("medium");
});

it("should trigger heavy haptic feedback on touch start for destructive intent", () => {
render(<Button label="Test button" intent="destructive" />);
it("should trigger heavy haptic feedback when a destructive press completes", () => {
render(
<Button label="Test button" intent="destructive" onClick={() => {}} />,
);

const button = screen.getByRole("button");
fireEvent.touchStart(button, {
touches: [{ clientX: 0, clientY: 0 }],
});
fireEvent.pointerUp(screen.getByRole("button"), { pointerType: "touch" });

expect(mockImpact).toHaveBeenCalledWith("heavy");
});

it("should not trigger haptic feedback when disabled", () => {
render(<Button label="Test button" intent="primary" disabled />);
render(
<Button
label="Test button"
intent="primary"
disabled
onClick={() => {}}
/>,
);

const button = screen.getByRole("button", { name: "Test button" });
fireEvent.touchStart(button, {
touches: [{ clientX: 0, clientY: 0 }],
});
fireEvent.pointerUp(screen.getByRole("button"), { pointerType: "touch" });

expect(mockImpact).not.toHaveBeenCalled();
});

it("should not trigger haptic feedback for decorative buttons", () => {
render(<Button icon={<span>Icon</span>} decorative />);

const button = screen.getByText("Icon").closest("button");
if (!button) throw new Error("Expected decorative button");
fireEvent.pointerUp(button, { pointerType: "touch" });

expect(mockImpact).not.toHaveBeenCalled();
});
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/unit/components/wui/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { render, screen, fireEvent } from "../../../../test-utils";
import userEvent from "@testing-library/user-event";
import {
createMemoryHistory,
createRootRoute,
createRouter,
Link,
RouterProvider,
} from "@tanstack/react-router";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Card } from "../../../../components/wui/Card";

const mockHapticPress = vi.fn();
vi.mock("@/hooks/useHapticPress", () => ({
useHapticPress: () => mockHapticPress,
}));

describe("Card", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -23,6 +36,41 @@ describe("Card", () => {
expect(mockOnClick).toHaveBeenCalledTimes(1);
});

it("should provide haptic feedback for a touchscreen press", async () => {
const user = userEvent.setup();
render(<Card onClick={() => {}}>Clickable content</Card>);

await user.pointer({
keys: "[TouchA]",
target: screen.getByRole("button"),
});

expect(mockHapticPress).toHaveBeenCalledTimes(1);
});

it("should provide haptic feedback when pressable inside a link", async () => {
const user = userEvent.setup();
const rootRoute = createRootRoute({
component: () => (
<Link to="/">
<Card pressable>Linked card</Card>
</Link>
),
});
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: ["/"] }),
});
render(<RouterProvider router={router} />);

await user.pointer({
keys: "[TouchA]",
target: await screen.findByText("Linked card"),
});

expect(mockHapticPress).toHaveBeenCalledTimes(1);
});

it("should handle Enter key for keyboard navigation", () => {
const mockOnClick = vi.fn();
render(<Card onClick={mockOnClick}>Clickable content</Card>);
Expand Down Expand Up @@ -65,5 +113,6 @@ describe("Card", () => {
fireEvent.click(card);

expect(mockOnClick).not.toHaveBeenCalled();
expect(mockHapticPress).not.toHaveBeenCalled();
});
});
Loading