diff --git a/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx b/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx new file mode 100644 index 0000000000000..d1ca0d781e26a --- /dev/null +++ b/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx @@ -0,0 +1,54 @@ +import type { DeviceSummary } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { DeviceToolsPanel } from "./DeviceToolsPanel"; +import type { DeviceControls } from "./useDeviceControls"; + +const device: DeviceSummary = { + hostId: "local", + id: "emulator", + name: "Pixel", + platform: "android", + version: "Android 15", + booted: true, + physical: false, +}; + +it("keeps a typed URL until opening it succeeds", async () => { + const run = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const controls: DeviceControls = { + detail: null, + pending: false, + error: null, + act: run, + disabled: false, + foregroundApp: null, + }; + await using screen = await render( + {}} + onClose={() => {}} + />, + ); + const url = "myapp://settings"; + const field = screen.getByPlaceholder("https://… or myapp://"); + const open = screen.getByRole("button", { name: "Open" }); + + await userEvent.fill(field, url); + await userEvent.click(open); + await act(() => run.mock.results[0]?.value); + await expect.element(field).toHaveValue(url); + + await userEvent.click(open); + await expect.element(field).toHaveValue(""); +}); diff --git a/apps/web/src/components/device/DeviceToolsPanel.tsx b/apps/web/src/components/device/DeviceToolsPanel.tsx index 23c5d0097082c..0d7c5b9f763e8 100644 --- a/apps/web/src/components/device/DeviceToolsPanel.tsx +++ b/apps/web/src/components/device/DeviceToolsPanel.tsx @@ -312,7 +312,7 @@ export function DeviceToolsPanel(props: { onSubmit={(payload) => foregroundApp ? act({ type: "sendPush", appId: foregroundApp.id, payload }) - : Promise.resolve() + : Promise.resolve(false) } /> {!foregroundApp ? ( @@ -349,7 +349,7 @@ function SwitchRow(props: { readonly label: string; readonly checked: boolean | undefined; readonly disabled: boolean; - readonly onChange: (value: boolean) => Promise; + readonly onChange: (value: boolean) => Promise; }) { return ( @@ -370,7 +370,7 @@ function ChoiceSelect(props: { readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; readonly disabled: boolean; readonly placeholder?: string; - readonly onChange: (value: V) => Promise; + readonly onChange: (value: V) => Promise; }) { const current = props.options.find((option) => option.value === props.value); return ( @@ -405,13 +405,15 @@ function SubmitRow(props: { readonly placeholder: string; readonly action: string; readonly disabled: boolean; - readonly onSubmit: (value: string) => Promise; + readonly onSubmit: (value: string) => Promise; }) { const [value, setValue] = useState(""); const submit = () => { const trimmed = value.trim(); if (!trimmed) return; - void props.onSubmit(trimmed).then(() => setValue("")); + void props.onSubmit(trimmed).then((sent) => { + if (sent) setValue(""); + }); }; return (
Promise; - readonly onClear: () => Promise; + readonly onSet: (latitude: number, longitude: number) => Promise; + readonly onClear: () => Promise; }) { const [latitude, setLatitude] = useState(""); const [longitude, setLongitude] = useState(""); @@ -541,7 +543,7 @@ function PermissionsSection(props: { appId: string, permission: DevicePermission, decision: "grant" | "revoke" | "reset", - ) => Promise; + ) => Promise; }) { const [appId, setAppId] = useState(""); const [permission, setPermission] = useState("camera"); diff --git a/apps/web/src/components/device/useDeviceControls.browser.test.tsx b/apps/web/src/components/device/useDeviceControls.browser.test.tsx index 5633369dc1cb2..85c484bfd520c 100644 --- a/apps/web/src/components/device/useDeviceControls.browser.test.tsx +++ b/apps/web/src/components/device/useDeviceControls.browser.test.tsx @@ -116,21 +116,23 @@ it("serializes rapid actions and retains confirmed settings while pending or aft await vi.waitFor(() => expect(controls.detail?.settings.appearance).toBe("light")); const response = deferred(); action.mockReturnValueOnce(response.promise); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); - await controls.act({ type: "setAppearance", value: "light" }); + expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false); }); expect(action).toHaveBeenCalledOnce(); expect(controls.disabled).toBe(true); expect(controls.detail?.settings.appearance).toBe("light"); await act(async () => { response.resolve(snapshot("dark")); - await pending; + expect(await pending).toBe(true); }); expect(controls.detail?.settings.appearance).toBe("dark"); action.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); - await act(async () => controls.act({ type: "setAppearance", value: "light" })); + await act(async () => { + expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false); + }); expect(controls.detail?.settings.appearance).toBe("dark"); expect(controls.error).toBe("Device offline"); expect(controls.disabled).toBe(false); @@ -142,7 +144,7 @@ it("confirms settings again after a command sent before the panel reopened", asy await vi.waitFor(() => expect(controls.detail).not.toBeNull()); const response = deferred(); action.mockReturnValueOnce(response.promise); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); @@ -174,7 +176,7 @@ it("does not let a read sent before an action overwrite the action's answer", as await screen.rerender(view(true)); await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)); action.mockResolvedValueOnce(snapshot("dark")); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); diff --git a/apps/web/src/components/device/useDeviceControls.ts b/apps/web/src/components/device/useDeviceControls.ts index 7b17bc4a624e1..31e67b8f3824f 100644 --- a/apps/web/src/components/device/useDeviceControls.ts +++ b/apps/web/src/components/device/useDeviceControls.ts @@ -39,7 +39,7 @@ export function useDeviceControls(options: { const available = detail !== null && visible; const act = async (body: ActionBody) => { - if (busy.current || !available) return; + if (busy.current || !available) return false; busy.current = true; setPending(true); setView((current) => ({ ...current, error: null })); @@ -47,13 +47,14 @@ export function useDeviceControls(options: { environmentId, input: { hostId, deviceId, ...body } as DeviceActionInput, }) - .then((result) => + .then((result) => { setView((current) => result._tag === "Success" ? { detail: result.value, error: null } : { ...current, error: formatEnvironmentQueryError(result.cause) }, - ), - ) + ); + return result._tag === "Success"; + }) .finally(() => { busy.current = false; setPending(false);