diff --git a/assets/js/live_svelte/index.ts b/assets/js/live_svelte/index.ts index d088b90..bb4e4fe 100644 --- a/assets/js/live_svelte/index.ts +++ b/assets/js/live_svelte/index.ts @@ -17,3 +17,5 @@ export type { } from "./useLiveForm"; export { useLiveUpload } from "./useLiveUpload"; export type { UploadEntry, UploadConfig, UploadOptions, UseLiveUploadReturn } from "./useLiveUpload"; +export { useEventReply } from "./useEventReply"; +export type { UseEventReplyOptions, UseEventReplyReturn } from "./useEventReply"; diff --git a/assets/js/live_svelte/types.d.ts b/assets/js/live_svelte/types.d.ts index 5dc7438..9b81eda 100644 --- a/assets/js/live_svelte/types.d.ts +++ b/assets/js/live_svelte/types.d.ts @@ -359,3 +359,58 @@ export declare function useLiveUpload( options: UploadOptions ): UseLiveUploadReturn; +// --------------------------------------------------------------------------- +// useEventReply types +// Keep in sync with useEventReply.ts — that file is the source of truth. +// --------------------------------------------------------------------------- + +/** Options for `useEventReply`. */ +export interface UseEventReplyOptions { + /** Initial value for `data` store before first reply. Defaults to `null`. */ + defaultValue?: T; + /** + * Transform the reply before storing it in `data`. + * Receives the server reply and current store value; return value is stored. + */ + updateData?: (reply: T, currentData: T | null) => T; + /** Reject the promise if the LiveView has not replied within this many milliseconds. */ + timeout?: number; +} + +/** Return value of `useEventReply`. */ +export interface UseEventReplyReturn { + /** Reactive store of the last reply data (`null` until first successful reply). */ + data: Readable; + /** `true` while the event is in-flight, `false` otherwise. */ + isLoading: Readable; + /** + * Push the named event to Phoenix with optional params. + * Returns a promise that resolves with the reply payload from `{:reply, map, socket}`. + * Rejects if already executing, if no LiveSvelte context, or if timeout expires. + */ + execute(params?: P): Promise; + /** + * Cancel the in-flight execution. + * Rejects the pending promise and resets `isLoading` to `false`. + */ + cancel(): void; +} + +/** + * Bind a LiveView event to a reactive request-response composable. + * + * @example + * ```svelte + * + * + * {#if $data}

{$data.result}

{/if} + * ``` + */ +export declare function useEventReply( + eventName: string, + options?: UseEventReplyOptions +): UseEventReplyReturn; + diff --git a/assets/js/live_svelte/useEventReply.test.ts b/assets/js/live_svelte/useEventReply.test.ts new file mode 100644 index 0000000..743c037 --- /dev/null +++ b/assets/js/live_svelte/useEventReply.test.ts @@ -0,0 +1,408 @@ +/** + * Tests for useEventReply composable. + * + * Mocking strategy: + * - `svelte` module is mocked: getContext (for useLiveSvelte), onDestroy (capture cleanup). + * vi.mock is hoisted before imports. + * - onDestroy does NOT auto-invoke — tests capture it and call manually when needed. + * - mockPushEvent captures the reply callback so tests can simulate server replies. + * - jsdom environment (from vitest.config.js) is sufficient — no DOM setup needed + * since useEventReply has no DOM operations. + */ + +import { vi, describe, it, expect, beforeEach } from "vitest" +import { get } from "svelte/store" + +// CRITICAL: vi.mock is hoisted before imports by Vitest. Must appear before +// any import of the module under test. +vi.mock("svelte", () => ({ + getContext: vi.fn(), + onDestroy: vi.fn(), +})) + +import { getContext, onDestroy } from "svelte" +import { useEventReply } from "./useEventReply" +import type { UseEventReplyOptions } from "./useEventReply" + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +// Captures the last pushEvent reply callback so tests can trigger server replies. +let lastReplyCallback: ((reply: unknown, ref: number) => void) | null = null + +const mockPushEvent = vi + .fn() + .mockImplementation( + ( + _event: string, + _params: object, + callback: (reply: unknown, ref: number) => void + ) => { + lastReplyCallback = callback + return 1 + } + ) + +const mockLive = { + pushEvent: mockPushEvent, + pushEventTo: vi.fn(), + handleEvent: vi.fn().mockReturnValue(() => {}), + removeHandleEvent: vi.fn(), + upload: vi.fn(), + uploadTo: vi.fn(), + liveSocket: undefined, +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks() + lastReplyCallback = null + vi.mocked(getContext).mockReturnValue(mockLive) +}) + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +describe("initialization", () => { + it("returns all expected properties", () => { + const result = useEventReply("compute") + expect(result).toHaveProperty("data") + expect(result).toHaveProperty("isLoading") + expect(result).toHaveProperty("execute") + expect(result).toHaveProperty("cancel") + }) + + it("data store initializes to null when no defaultValue provided", () => { + const { data } = useEventReply("compute") + expect(get(data)).toBeNull() + }) + + it("data store initializes to defaultValue when provided", () => { + const { data } = useEventReply("compute", { defaultValue: { result: 0 } }) + expect(get(data)).toEqual({ result: 0 }) + }) + + it("isLoading store initializes to false", () => { + const { isLoading } = useEventReply("compute") + expect(get(isLoading)).toBe(false) + }) + + it("registers onDestroy cleanup during init", () => { + useEventReply("compute") + expect(vi.mocked(onDestroy)).toHaveBeenCalledTimes(1) + expect(typeof vi.mocked(onDestroy).mock.calls[0][0]).toBe("function") + }) +}) + +// --------------------------------------------------------------------------- +// execute() — happy path +// --------------------------------------------------------------------------- + +describe("execute()", () => { + it("returns a Promise", () => { + const { execute } = useEventReply("compute") + const result = execute({}) + expect(result).toBeInstanceOf(Promise) + // Prevent unhandled rejection in test environment. + result.catch(() => {}) + }) + + it("sets isLoading to true immediately when called", () => { + const { isLoading, execute } = useEventReply("compute") + const promise = execute({}) + expect(get(isLoading)).toBe(true) + promise.catch(() => {}) + }) + + it("calls pushEvent with the event name and params", () => { + const { execute } = useEventReply("compute") + execute({ value: 42 }) + expect(mockPushEvent).toHaveBeenCalledWith( + "compute", + { value: 42 }, + expect.any(Function) + ) + }) + + it("calls pushEvent with empty object when no params given", () => { + const { execute } = useEventReply("compute") + execute() + expect(mockPushEvent).toHaveBeenCalledWith("compute", {}, expect.any(Function)) + }) + + it("resolves with reply payload from server", async () => { + const { execute } = useEventReply<{ result: number }>("compute") + const promise = execute({ value: 21 }) + + expect(lastReplyCallback).not.toBeNull() + lastReplyCallback!({ result: 42 }, 1) + + const reply = await promise + expect(reply).toEqual({ result: 42 }) + }) + + it("updates data store when reply arrives", async () => { + const { data, execute } = useEventReply<{ result: number }>("compute") + const promise = execute({}) + + lastReplyCallback!({ result: 99 }, 1) + await promise + + expect(get(data)).toEqual({ result: 99 }) + }) + + it("sets isLoading to false after reply", async () => { + const { isLoading, execute } = useEventReply("compute") + const promise = execute({}) + expect(get(isLoading)).toBe(true) + + lastReplyCallback!({ result: 1 }, 1) + await promise + + expect(get(isLoading)).toBe(false) + }) + + it("applies updateData transformer before storing reply", async () => { + const options: UseEventReplyOptions<{ total: number }> = { + defaultValue: { total: 0 }, + updateData: (reply, current) => ({ total: (current?.total ?? 0) + reply.total }), + } + const { data, execute } = useEventReply<{ total: number }>("accumulate", options) + const p1 = execute({}) + lastReplyCallback!({ total: 10 }, 1) + await p1 + + const p2 = execute({}) + lastReplyCallback!({ total: 5 }, 1) + await p2 + + expect(get(data)).toEqual({ total: 15 }) + }) +}) + +// --------------------------------------------------------------------------- +// execute() — already loading guard +// --------------------------------------------------------------------------- + +describe("execute() while already loading", () => { + it("rejects if already executing", async () => { + const { execute } = useEventReply("compute") + execute({}) // First call — in flight + + await expect(execute({})).rejects.toThrow("is already executing") + }) + + it("logs a console.warn when already executing", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + const { execute } = useEventReply("compute") + execute({}) + execute({}).catch(() => {}) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("already executing")) + } finally { + warnSpy.mockRestore() + } + }) + + it("does not call pushEvent a second time when already loading", () => { + const { execute } = useEventReply("compute") + execute({}) + execute({}).catch(() => {}) + expect(mockPushEvent).toHaveBeenCalledTimes(1) + }) +}) + +// --------------------------------------------------------------------------- +// cancel() +// --------------------------------------------------------------------------- + +describe("cancel()", () => { + it("rejects the pending promise", async () => { + const { execute, cancel } = useEventReply("compute") + const promise = execute({}) + cancel() + await expect(promise).rejects.toThrow("was cancelled") + }) + + it("resets isLoading to false", () => { + const { isLoading, execute, cancel } = useEventReply("compute") + const p = execute({}) + p.catch(() => {}) + expect(get(isLoading)).toBe(true) + cancel() + expect(get(isLoading)).toBe(false) + }) + + it("stale reply callback is ignored after cancel", async () => { + const { data, execute, cancel } = useEventReply<{ result: number }>("compute") + const promise = execute({}) + // Attach catch handler BEFORE cancel() to prevent unhandled rejection. + const handled = promise.catch(() => {}) + const capturedCallback = lastReplyCallback + + cancel() + + // Try to invoke the stale callback — should be ignored. + capturedCallback!({ result: 99 }, 1) + + // data store should remain unchanged. + expect(get(data)).toBeNull() + + await handled + }) + + it("cancel() does nothing when not executing", () => { + const { isLoading, cancel } = useEventReply("compute") + expect(() => cancel()).not.toThrow() + expect(get(isLoading)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Timeout option +// --------------------------------------------------------------------------- + +describe("timeout option", () => { + it("rejects promise after timeout elapses", async () => { + vi.useFakeTimers() + try { + const { execute } = useEventReply("slow-event", { timeout: 500 }) + const promise = execute({}) + vi.advanceTimersByTime(501) + await expect(promise).rejects.toThrow("timed out after 500ms") + } finally { + vi.useRealTimers() + } + }) + + it("resets isLoading after timeout", async () => { + vi.useFakeTimers() + try { + const { isLoading, execute } = useEventReply("slow-event", { timeout: 300 }) + const promise = execute({}) + expect(get(isLoading)).toBe(true) + vi.advanceTimersByTime(301) + expect(get(isLoading)).toBe(false) + await promise.catch(() => {}) + } finally { + vi.useRealTimers() + } + }) + + it("does NOT reject if reply arrives before timeout", async () => { + vi.useFakeTimers() + try { + const { execute } = useEventReply<{ result: number }>("fast-event", { timeout: 1000 }) + const promise = execute({}) + // Reply arrives before timeout. + lastReplyCallback!({ result: 7 }, 1) + vi.advanceTimersByTime(999) + const reply = await promise + expect(reply).toEqual({ result: 7 }) + } finally { + vi.useRealTimers() + } + }) + + it("data store is unchanged after timeout", async () => { + vi.useFakeTimers() + try { + const { data, execute } = useEventReply<{ result: number }>("slow-event", { + defaultValue: { result: 0 }, + timeout: 500, + }) + const promise = execute({}) + vi.advanceTimersByTime(501) + await promise.catch(() => {}) + expect(get(data)).toEqual({ result: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it("stale timeout callback is ignored after cancel", async () => { + vi.useFakeTimers() + try { + const { data, execute, cancel } = useEventReply<{ result: number }>("slow-event", { + timeout: 500, + }) + const promise = execute({}) + cancel() + vi.advanceTimersByTime(501) + // Should not throw or update state. + expect(get(data)).toBeNull() + await promise.catch(() => {}) + } finally { + vi.useRealTimers() + } + }) +}) + +// --------------------------------------------------------------------------- +// onDestroy cleanup +// --------------------------------------------------------------------------- + +describe("onDestroy cleanup", () => { + it("cancels any pending execution when component is destroyed", () => { + const { isLoading, execute } = useEventReply("compute") + const destroyFn = vi.mocked(onDestroy).mock.calls[0][0] as () => void + + const p = execute({}) + p.catch(() => {}) + expect(get(isLoading)).toBe(true) + + destroyFn() + expect(get(isLoading)).toBe(false) + }) + + it("rejects pending promise on destroy", async () => { + const { execute } = useEventReply("compute") + const destroyFn = vi.mocked(onDestroy).mock.calls[0][0] as () => void + + const promise = execute({}) + destroyFn() + + await expect(promise).rejects.toThrow("was cancelled") + }) +}) + +// --------------------------------------------------------------------------- +// Graceful degradation without LiveSvelte context +// --------------------------------------------------------------------------- + +describe("graceful degradation without LiveSvelte context", () => { + beforeEach(() => { + vi.mocked(getContext).mockReturnValue(null) + }) + + it("initialises without throwing when context is absent", () => { + expect(() => useEventReply("compute")).not.toThrow() + }) + + it("execute() returns a rejected promise when context is absent", async () => { + const { execute } = useEventReply("compute") + await expect(execute({})).rejects.toThrow("no LiveSvelte context") + }) + + it("does not call pushEvent when context is absent", async () => { + const { execute } = useEventReply("compute") + await execute({}).catch(() => {}) + expect(mockPushEvent).not.toHaveBeenCalled() + }) + + it("data and isLoading stores still work without live context", () => { + const { data, isLoading } = useEventReply("compute", { defaultValue: { x: 1 } }) + expect(get(data)).toEqual({ x: 1 }) + expect(get(isLoading)).toBe(false) + }) + + it("cancel() does not throw when context is absent", () => { + const { cancel } = useEventReply("compute") + expect(() => cancel()).not.toThrow() + }) +}) diff --git a/assets/js/live_svelte/useEventReply.ts b/assets/js/live_svelte/useEventReply.ts new file mode 100644 index 0000000..1ccb21a --- /dev/null +++ b/assets/js/live_svelte/useEventReply.ts @@ -0,0 +1,174 @@ +/** + * useEventReply — Svelte composable for Phoenix LiveView request-response events. + * + * Binds a named event to pushEvent, returning reactive `data` and `isLoading` stores + * plus a promise-based `execute()` that resolves with the server's {:reply, map, socket}. + * + * Usage: + * ```svelte + * + * + * + * {#if $data} + *

Result: {$data.result}

+ * {/if} + * ``` + */ + +import { writable, get, type Readable, type Writable } from "svelte/store" +import { onDestroy } from "svelte" +import { useLiveSvelte } from "./composables" + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Options for `useEventReply`. */ +export interface UseEventReplyOptions { + /** Initial value for `data` store before first reply. Defaults to `null`. */ + defaultValue?: T + /** + * Transform the reply before storing it in `data`. + * Receives the server reply and current store value; return value is stored. + */ + updateData?: (reply: T, currentData: T | null) => T + /** Reject the promise if the LiveView has not replied within this many milliseconds. */ + timeout?: number +} + +/** Return value of `useEventReply`. */ +export interface UseEventReplyReturn { + /** Reactive store of the last reply data (`null` until first successful reply). */ + data: Readable + /** `true` while the event is in-flight, `false` otherwise. */ + isLoading: Readable + /** + * Push the named event to Phoenix with optional params. + * Returns a promise that resolves with the reply payload from `{:reply, map, socket}`. + * Rejects if already executing, if no LiveSvelte context, or if timeout expires. + */ + execute(params?: P): Promise + /** + * Cancel the in-flight execution. + * Rejects the pending promise and resets `isLoading` to `false`. + * The execution token is incremented so any stale in-flight callback is ignored. + */ + cancel(): void +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export function useEventReply( + eventName: string, + options?: UseEventReplyOptions +): UseEventReplyReturn { + // Graceful degradation: works without LiveSvelte context (SSR, tests without mock). + let liveCtx: ReturnType | null = null + try { + liveCtx = useLiveSvelte() + } catch { + // SSR or test without LiveSvelte context — pushEvent unavailable. + } + + // Core reactive stores. + const dataStore: Writable = writable(options?.defaultValue ?? null) + const isLoadingStore: Writable = writable(false) + + // Execution-token counter — incremented on each new execution or cancellation. + // Used to ignore stale callbacks from previous (superseded) executions. + let executionToken = 0 + + // Stored so cancel() can reject the pending promise. + let pendingReject: ((err: Error) => void) | null = null + + function execute(params?: P): Promise { + if (get(isLoadingStore)) { + console.warn( + `useEventReply: "${eventName}" is already executing. Call cancel() first.` + ) + return Promise.reject( + new Error(`useEventReply: "${eventName}" is already executing`) + ) + } + + if (!liveCtx) { + return Promise.reject( + new Error( + `useEventReply: no LiveSvelte context — "${eventName}" must be called inside a LiveSvelte-mounted component` + ) + ) + } + + isLoadingStore.set(true) + const currentToken = ++executionToken + + return new Promise((resolve, reject) => { + pendingReject = reject + + // Optional timeout: reject if the LiveView hasn't replied in time. + let timeoutId: ReturnType | undefined + if (options?.timeout != null) { + timeoutId = setTimeout(() => { + if (currentToken === executionToken) { + executionToken++ + isLoadingStore.set(false) + pendingReject = null + reject( + new Error( + `useEventReply: "${eventName}" timed out after ${options.timeout}ms` + ) + ) + } + }, options.timeout) + } + + liveCtx!.pushEvent( + eventName, + (params as object | undefined) ?? {}, + (reply: unknown, _ref: number) => { + // Only update state if this is still the current execution. + if (currentToken === executionToken) { + if (timeoutId != null) clearTimeout(timeoutId) + const typedReply = reply as T + const updated = options?.updateData + ? options.updateData(typedReply, get(dataStore)) + : typedReply + dataStore.set(updated) + isLoadingStore.set(false) + pendingReject = null + resolve(typedReply) + } + // Token mismatch: execution was cancelled — ignore stale reply. + } + ) + }) + } + + function cancel(): void { + if (pendingReject) { + pendingReject(new Error(`useEventReply: "${eventName}" was cancelled`)) + pendingReject = null + } + // Increment token to invalidate any in-flight callbacks. + executionToken++ + isLoadingStore.set(false) + } + + // Auto-cancel on component destroy to prevent dangling promises. + onDestroy(() => cancel()) + + return { + data: { subscribe: dataStore.subscribe }, + isLoading: { subscribe: isLoadingStore.subscribe }, + execute, + cancel, + } +} diff --git a/example_project/assets/svelte/EventReplyDemo.svelte b/example_project/assets/svelte/EventReplyDemo.svelte new file mode 100644 index 0000000..dbecd27 --- /dev/null +++ b/example_project/assets/svelte/EventReplyDemo.svelte @@ -0,0 +1,41 @@ + + +
+
+

Request-Response Demo

+ +
+ + +
+ +
+ + +
+ + {#if $data} +
+ + Result: {$data.result} + (input was {$data.input}) + +
+ {/if} +
+
diff --git a/example_project/lib/example_web/components/layouts.ex b/example_project/lib/example_web/components/layouts.ex index 16e64e2..c930085 100644 --- a/example_project/lib/example_web/components/layouts.ex +++ b/example_project/lib/example_web/components/layouts.ex @@ -62,7 +62,8 @@ defmodule ExampleWeb.Layouts do %{label: "Form (useLiveForm)", to: ~p"/live-form"}, %{label: "Navigation (useLiveNavigation)", to: ~p"/live-navigation"}, %{label: "Composition (useLiveSvelte)", to: ~p"/live-composition"}, - %{label: "File Upload (useLiveUpload)", to: ~p"/live-upload"} + %{label: "File Upload (useLiveUpload)", to: ~p"/live-upload"}, + %{label: "Event Reply (useEventReply)", to: ~p"/live-event-reply"} ] } ] diff --git a/example_project/lib/example_web/controllers/page_html/home.html.heex b/example_project/lib/example_web/controllers/page_html/home.html.heex index 13de244..11a50f8 100644 --- a/example_project/lib/example_web/controllers/page_html/home.html.heex +++ b/example_project/lib/example_web/controllers/page_html/home.html.heex @@ -11,8 +11,7 @@

- 1 - Basics + 1 Basics

  • @@ -41,15 +40,11 @@

    - 2 - Interactive + 2 Interactive

    • - + Counter - Server + client state @@ -67,10 +62,7 @@ - Inline Svelte with ~V sigil
    • - + Plus/Minus (Static) - Dead view integration @@ -82,10 +74,7 @@ - LiveView integration
    • - + Hybrid Counter - Client + server events @@ -104,8 +93,7 @@

      - 3 - Data + 3 Data

      • @@ -115,10 +103,7 @@ - Dynamic list updates
      • - + Breaking News - Real-time updates with ~V sigil @@ -145,7 +130,9 @@ ID List Diff - - ID-based list diffing with minimal patch ops + + - ID-based list diffing with minimal patch ops +
      @@ -155,24 +142,17 @@

      - 4 - Slots + 4 Slots

      • - + Simple Slots - Basic slot usage
      • - + Dynamic Slots - Named slots with dynamic content @@ -185,15 +165,11 @@

        - 5 - Advanced + 5 Advanced

        • - + Client Loading - Loading states and SSR @@ -206,8 +182,7 @@

          - 6 - Ecto + 6 Ecto

          • @@ -224,15 +199,16 @@

            - 7 - Composables + 7 Composables

            @@ -260,11 +248,7 @@

            View the source code on - + GitHub

            diff --git a/example_project/lib/example_web/live/live_event_reply.ex b/example_project/lib/example_web/live/live_event_reply.ex new file mode 100644 index 0000000..eb5b48e --- /dev/null +++ b/example_project/lib/example_web/live/live_event_reply.ex @@ -0,0 +1,32 @@ +defmodule ExampleWeb.LiveEventReply do + @moduledoc """ + LiveView demo for the `useEventReply()` composable. + Demonstrates request-response pattern: push event, await typed reply. + """ + use ExampleWeb, :live_view + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_event("compute", %{"value" => value}, socket) do + input = value || 0 + {:reply, %{result: input * 2, input: input}, socket} + end + + def handle_event("compute", _params, socket) do + {:reply, %{result: 0, input: 0}, socket} + end + + def render(assigns) do + ~H""" +
            +

            Event Reply (useEventReply)

            +

            + Push an event to Phoenix and receive a typed reply via promise. +

            + <.svelte name="EventReplyDemo" props={%{}} socket={@socket} /> +
            + """ + end +end diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex index 025b6df..6856278 100644 --- a/example_project/lib/example_web/router.ex +++ b/example_project/lib/example_web/router.ex @@ -43,9 +43,9 @@ defmodule ExampleWeb.Router do live "/live-notes-otp", LiveNotesOtp live "/live-form", LiveForm live "/live-upload", LiveUpload + live "/live-event-reply", LiveEventReply live "/live-navigation", LiveNavigation live "/live-navigation/:page", LiveNavigation - # not referenced in app.html.heex: live "/live-composition", LiveComposition end diff --git a/example_project/test/example_web/live/live_event_reply_test.exs b/example_project/test/example_web/live/live_event_reply_test.exs new file mode 100644 index 0000000..7209d15 --- /dev/null +++ b/example_project/test/example_web/live/live_event_reply_test.exs @@ -0,0 +1,49 @@ +defmodule ExampleWeb.LiveEventReplyTest do + @moduledoc """ + E2E tests for the LiveEventReply LiveView (/live-event-reply). + Validates useEventReply() composable: initial render and request-response flow. + """ + use ExampleWeb.FeatureCase, async: false + + @moduletag :e2e + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # Wait for an element with the given testid to appear (retries up to 3s). + defp wait_for(session, testid, attempts \\ 30) + defp wait_for(_session, testid, 0), do: raise("timeout waiting for [data-testid='#{testid}']") + + defp wait_for(session, testid, attempts) do + els = session |> all(Query.css("[data-testid='#{testid}']")) + + if length(els) > 0 do + session + else + :timer.sleep(100) + wait_for(session, testid, attempts - 1) + end + end + + # --------------------------------------------------------------------------- + # Tests + # --------------------------------------------------------------------------- + + test "initial render shows compute button with no result", %{session: session} do + session + |> visit("/live-event-reply") + |> wait_for("compute-btn") + |> assert_has(Query.css("[data-testid='compute-btn']")) + |> refute_has(Query.css("[data-testid='reply-result']")) + end + + test "clicking compute shows result from Phoenix reply", %{session: session} do + session + |> visit("/live-event-reply") + |> wait_for("compute-btn") + |> click(Query.css("[data-testid='compute-btn']")) + |> wait_for("reply-result") + |> assert_has(Query.css("[data-testid='result-value']")) + end +end