Files
kandev/apps/web/components/task/chat/voice-input-button.test.tsx
T
João Salavisa aea266ee71 fix(web): fix hold-to-talk on mobile and add coarse-pointer toggle fallback (#1231)
* fix(web): make voice-mode hold gesture survive touch and add mobile coverage

Mobile users with the chat composer's mic button in hold-to-talk mode lost
their recording the moment a finger crept off the 28px target, because
`buildHoldHandlers` aborted on `pointerleave`/`pointercancel` without ever
claiming the pointer.

- `buildHoldHandlers` now claims the pointer on `pointerdown` via
  `setPointerCapture` and releases it on `pointerup`/`pointercancel`. The
  `pointerleave` handler is dropped — capture keeps the gesture alive across
  small finger movement, OS gesture handoff, and soft-keyboard reflows.
- `EnabledVoiceInputButton` resolves an `effectiveMode` via a coarse-pointer
  check (`matchMedia("(pointer: coarse)")`); when the stored preference is
  `hold` and the device is touch-first, the mic falls back to toggle so a
  bad stored preference can't trap users in a broken UX. Persisted
  `voiceMode.mode` is left untouched so docking a keyboard restores the
  user's choice without a save.
- The button bumps to 40×40 on coarse-pointer devices to meet the 44pt touch-
  target floor without disturbing the desktop submit/voice pair.
- The unsupported fallback path (`UnsupportedVoiceButton`) is unchanged so
  LAN-HTTP mobile users still get the HTTPS explanation toast.

Adds `voice-input-button.test.tsx` covering capture/release on pointer
events, the absence of `pointerleave` regression, the coarse-pointer toggle
override, and the touch-target class swap.

Adds `mobile-voice-mode.spec.ts` (Playwright mobile-chrome / Pixel 5)
verifying the Voice Mode settings page is reachable from the mobile sidebar
trigger and that the mic button mounts with `data-effective-mode="toggle"`
on the mobile chat composer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(web): drop unused testPage param from mobile-voice-mode seedTask helper

The helper only calls apiClient and does not touch the Playwright Page
object — the parameter was carried over from the neighbour spec's pattern
where the helper also navigates. Removing it also drops the now-unused
`Page` type import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(web): drop redundant Element type cast from safePointerRelease

`Element.hasPointerCapture` and `releasePointerCapture` are already declared
in lib.dom.d.ts, so the `target as Element & { hasPointerCapture?: ... }`
casts were over-defensive. Replace with a direct `typeof` runtime check,
which still guards against the headless DOMs that strip these methods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(web): assert coarse-pointer touch-target class in mobile voice spec

The previous assertion (`data-effective-mode === "toggle"`) would have
passed even if the coarse-pointer override were reverted, since the default
stored `voiceMode.mode` is already `"toggle"`. Add a check on the `h-10
w-10` class swap — that class pair is only emitted when
`useIsCoarsePointer()` returned true, so the test now genuinely exercises
the override on the e2e surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(web): extend voice-input-button unit coverage for toggle paths

- Toggle-mode + fine-pointer: pointerdown must not start recording; click
  drives the toggle. Guards against an accidental future change that wires
  hold pointer handlers when stored mode is "toggle".
- Recording state + click: stop() fires (not start()), exercising the
  buildToggleHandler branch the original suite skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(web): seed hold mode in E2E and fix misleading test title

- Add `voice_mode` field to `saveUserSettings` in api-client.ts so E2E
  tests can PATCH the user's stored activation mode via the API.
- Seed `mode: "hold"` before the mic-button assertion so
  `data-mode="hold"` + `data-effective-mode="toggle"` together prove the
  coarse-pointer override fired (not that the default was already toggle).
- Rename settings-page test to drop the "from mobile sidebar" claim —
  the test navigates directly to the URL; sidebar existence is still
  asserted as a regression guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:45:50 +01:00

246 lines
8.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
// ── Hoisted mocks (must be set before importing the component under test) ──
const voicePrefs = vi.hoisted(() => ({
value: {
enabled: true,
engine: "auto" as "auto" | "webSpeech" | "whisperWeb" | "whisperServer",
language: "auto",
mode: "hold" as "hold" | "toggle",
autoSend: false,
whisperWebModel: "base" as "tiny" | "base" | "small",
},
}));
const voiceInputResult = vi.hoisted(() => ({
supported: true as boolean,
state: "idle" as "idle" | "requesting" | "recording" | "processing",
modelLoad: { state: "idle" as const, progress: 0 },
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
cancel: vi.fn(() => undefined),
engine: "webSpeech" as const,
error: null as null | { code: string; message: string },
}));
const coarsePointer = vi.hoisted(() => ({ value: false }));
vi.mock("@/components/state-provider", () => ({
useAppStore: (
selector: (state: {
userSettings: {
voiceMode: typeof voicePrefs.value;
keyboardShortcuts: Record<string, unknown>;
};
}) => unknown,
) =>
selector({
userSettings: { voiceMode: voicePrefs.value, keyboardShortcuts: {} },
}),
}));
vi.mock("@/hooks/use-voice-input", () => ({
useVoiceInput: () => voiceInputResult,
}));
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
useKeyboardShortcut: () => undefined,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// ── matchMedia stub honouring the hoisted `coarsePointer.value` flag ──
beforeEach(() => {
voicePrefs.value = {
enabled: true,
engine: "auto",
language: "auto",
mode: "hold",
autoSend: false,
whisperWebModel: "base",
};
voiceInputResult.supported = true;
voiceInputResult.state = "idle";
voiceInputResult.modelLoad = { state: "idle", progress: 0 };
voiceInputResult.start.mockClear();
voiceInputResult.stop.mockClear();
voiceInputResult.cancel.mockClear();
coarsePointer.value = false;
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: query.includes("coarse") ? coarsePointer.value : false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(() => {
cleanup();
});
// Re-import after the mocks so the module under test sees the mocked deps.
import { VoiceInputButton } from "./voice-input-button";
const BUTTON_TESTID = "voice-input-button";
function renderButton() {
return render(<VoiceInputButton onTranscript={() => {}} onAutoSend={() => {}} />);
}
describe("VoiceInputButton — hold-mode pointer handlers", () => {
it("captures the pointer on pointerdown and starts recording", () => {
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
const setCap = vi.fn();
(button as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = setCap;
fireEvent.pointerDown(button, { pointerId: 7 });
expect(setCap).toHaveBeenCalledWith(7);
expect(voiceInputResult.start).toHaveBeenCalledTimes(1);
});
it("releases the pointer and stops on pointerup", () => {
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
const releaseCap = vi.fn();
(button as unknown as { hasPointerCapture: () => boolean }).hasPointerCapture = () => true;
(button as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture =
releaseCap;
fireEvent.pointerUp(button, { pointerId: 7 });
expect(releaseCap).toHaveBeenCalledWith(7);
expect(voiceInputResult.stop).toHaveBeenCalledTimes(1);
});
it("stops on pointercancel without triggering on pointerleave", () => {
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
(button as unknown as { hasPointerCapture: () => boolean }).hasPointerCapture = () => false;
(button as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture =
() => undefined;
// pointerleave used to abort recording. Confirm it no longer does.
fireEvent.pointerLeave(button, { pointerId: 7 });
expect(voiceInputResult.stop).not.toHaveBeenCalled();
fireEvent.pointerCancel(button, { pointerId: 7 });
expect(voiceInputResult.stop).toHaveBeenCalledTimes(1);
});
it("does not throw if setPointerCapture is not present on the element", () => {
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
// Some test/UA combinations strip setPointerCapture — handler must swallow.
expect(() => fireEvent.pointerDown(button, { pointerId: 1 })).not.toThrow();
});
});
describe("VoiceInputButton — coarse-pointer override", () => {
it("reports stored hold mode but effective toggle when matchMedia(coarse) matches", () => {
coarsePointer.value = true;
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
expect(button.getAttribute("data-mode")).toBe("hold");
expect(button.getAttribute("data-effective-mode")).toBe("toggle");
});
it("does not attach hold pointer handlers in coarse-pointer toggle fallback", () => {
coarsePointer.value = true;
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
fireEvent.pointerDown(button, { pointerId: 1 });
fireEvent.pointerUp(button, { pointerId: 1 });
// toggle handler is bound to onClick; pointer events alone should be inert.
expect(voiceInputResult.start).not.toHaveBeenCalled();
expect(voiceInputResult.stop).not.toHaveBeenCalled();
// Click is what drives toggle.
fireEvent.click(button);
expect(voiceInputResult.start).toHaveBeenCalledTimes(1);
});
it("preserves hold behaviour on fine-pointer devices", () => {
coarsePointer.value = false;
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
expect(button.getAttribute("data-effective-mode")).toBe("hold");
});
it("applies the 40px touch-target class on coarse-pointer devices", () => {
coarsePointer.value = true;
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
expect(button.className).toContain("h-10");
expect(button.className).toContain("w-10");
expect(button.className).not.toContain("h-7");
});
});
describe("VoiceInputButton — stored toggle mode is unaffected by pointer kind", () => {
it("renders effective toggle and ignores pointer events when stored mode is toggle", () => {
voicePrefs.value = { ...voicePrefs.value, mode: "toggle" };
coarsePointer.value = false;
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
expect(button.getAttribute("data-mode")).toBe("toggle");
expect(button.getAttribute("data-effective-mode")).toBe("toggle");
// Pointer handlers must not be attached in toggle mode; pointerdown alone
// should not start a recording. Click is what drives toggle.
fireEvent.pointerDown(button, { pointerId: 4 });
expect(voiceInputResult.start).not.toHaveBeenCalled();
fireEvent.click(button);
expect(voiceInputResult.start).toHaveBeenCalledTimes(1);
});
});
describe("VoiceInputButton — recording-state stop transition", () => {
it("invokes stop() on click when state is recording (toggle path)", () => {
voicePrefs.value = { ...voicePrefs.value, mode: "toggle" };
voiceInputResult.state = "recording";
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
fireEvent.click(button);
expect(voiceInputResult.stop).toHaveBeenCalledTimes(1);
expect(voiceInputResult.start).not.toHaveBeenCalled();
});
});
describe("VoiceInputButton — disabled feature & unsupported fallback", () => {
it("renders nothing when voice mode is disabled in settings", () => {
voicePrefs.value = { ...voicePrefs.value, enabled: false };
const { container } = renderButton();
expect(container.firstChild).toBeNull();
});
it("renders the unsupported fallback button when no engine is available", () => {
voiceInputResult.supported = false;
renderButton();
const button = screen.getByTestId(BUTTON_TESTID);
expect(button.getAttribute("data-state")).toBe("unsupported");
expect(button.getAttribute("aria-label")).toBe("Voice input unavailable");
});
});