aea266ee71
* 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>
246 lines
8.7 KiB
TypeScript
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");
|
|
});
|
|
});
|