diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..3b4db1a --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], + subdirectories: [] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f5e90d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: [pull_request, push] +jobs: + mix_test: + name: mix test (Elixir ${{ matrix.elixir }} OTP ${{ matrix.otp }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - elixir: 1.10.4 + otp: 21.3.8.16 + - elixir: 1.10.4 + otp: 23.0.3 + steps: + - uses: actions/checkout@v2.3.2 + - uses: actions/setup-elixir@v1.5.0 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Install Dependencies + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get --only test + - name: Check formatting + run: mix format --check-formatted + - name: Run tests + run: mix test diff --git a/lib/Discord/events.ex b/lib/Discord/events.ex index 3f42fe5..ff6ea96 100644 --- a/lib/Discord/events.ex +++ b/lib/Discord/events.ex @@ -28,6 +28,7 @@ defmodule Alchemy.Discord.Events do with {:ok, guild_id} <- Channels.lookup(channel_id) do Guilds.update_channel(guild_id, channel) end + {:channel_update, [Channel.from_map(channel)]} end @@ -40,6 +41,7 @@ defmodule Alchemy.Discord.Events do with {:ok, guild_id} <- Channels.lookup(channel_id) do Guilds.remove_channel(guild_id, channel_id) end + Channels.remove_channel(channel_id) {:channel_delete, [Channel.from_map(channel)]} end @@ -108,10 +110,10 @@ defmodule Alchemy.Discord.Events do def handle("GUILD_ROLE_UPDATE", %{"guild_id" => id, "role" => new_role = %{"id" => role_id}}) do guild_result = Guilds.safe_call(id, {:section, "roles"}) + old_role = with {:ok, guild} <- guild_result, - role when not is_nil(role) <- guild[role_id] - do + role when not is_nil(role) <- guild[role_id] do to_struct(role, Role) else _ -> nil diff --git a/lib/Structs/Channels/store_channel.ex b/lib/Structs/Channels/store_channel.ex index 6cdb687..cfd8b15 100644 --- a/lib/Structs/Channels/store_channel.ex +++ b/lib/Structs/Channels/store_channel.ex @@ -5,7 +5,7 @@ defmodule Alchemy.Channel.StoreChannel do # Note: should never encounter a store channel, as they're not something # bots can send/read to. It's "the store." - + defstruct [ :id, :guild_id, diff --git a/lib/Structs/Messages/Reactions/emoji.ex b/lib/Structs/Messages/Reactions/emoji.ex index 587393d..fb95e63 100644 --- a/lib/Structs/Messages/Reactions/emoji.ex +++ b/lib/Structs/Messages/Reactions/emoji.ex @@ -2,9 +2,7 @@ defmodule Alchemy.Reaction.Emoji do @moduledoc false @derive Poison.Encoder - defstruct [:id, - :name] - + defstruct [:id, :name] @doc false def resolve(emoji) do diff --git a/lib/Structs/Messages/Reactions/reaction.ex b/lib/Structs/Messages/Reactions/reaction.ex index b2084ce..8c97ee4 100644 --- a/lib/Structs/Messages/Reactions/reaction.ex +++ b/lib/Structs/Messages/Reactions/reaction.ex @@ -2,7 +2,5 @@ defmodule Alchemy.Reaction do @moduledoc false @derive Poison.Encoder - defstruct [:count, - :me, - :emoji] + defstruct [:count, :me, :emoji] end diff --git a/lib/Structs/Voice/voice.ex b/lib/Structs/Voice/voice.ex index 505b947..c2e49ce 100644 --- a/lib/Structs/Voice/voice.ex +++ b/lib/Structs/Voice/voice.ex @@ -17,7 +17,7 @@ defmodule Alchemy.Voice do alias Alchemy.Voice.Supervisor, as: VoiceSuper alias Alchemy.Discord.Gateway.RateLimiter - @type snowflake :: String.t + @type snowflake :: String.t() @typedoc """ Represents a voice region. @@ -40,15 +40,15 @@ defmodule Alchemy.Voice do Whether this is a custom voice region. """ @type region :: %VoiceRegion{ - id: snowflake, - name: String.t, - sample_hostname: String.t, - sample_port: Integer, - vip: Boolean, - optimal: Boolean, - deprecated: Boolean, - custom: Boolean - } + id: snowflake, + name: String.t(), + sample_hostname: String.t(), + sample_port: Integer, + vip: Boolean, + optimal: Boolean, + deprecated: Boolean, + custom: Boolean + } @typedoc """ Represents the state of a user's voice connection. @@ -72,16 +72,16 @@ defmodule Alchemy.Voice do Whether this user is muted by the current user. """ @type state :: %VoiceState{ - guild_id: snowflake | nil, - channel_id: snowflake, - user_id: snowflake, - session_id: String.t, - deaf: Boolean, - mute: Boolean, - self_deaf: Boolean, - self_mute: Boolean, - suppress: Boolean - } + guild_id: snowflake | nil, + channel_id: snowflake, + user_id: snowflake, + session_id: String.t(), + deaf: Boolean, + mute: Boolean, + self_deaf: Boolean, + self_mute: Boolean, + suppress: Boolean + } @typedoc """ Represents the audio options that can be passed to different play methods. @@ -103,28 +103,31 @@ defmodule Alchemy.Voice do The timeout will be spread across 2 different message receptions, i.e. a timeout of `6000` will only wait 3s at every reception. """ - @spec join(snowflake, snowflake, integer) :: :ok | {:error, String.t} + @spec join(snowflake, snowflake, integer) :: :ok | {:error, String.t()} def join(guild, channel, timeout \\ 6000) do case Registry.lookup(Registry.Voice, {guild, :gateway}) do - [{_, ^channel}|_] -> :ok + [{_, ^channel} | _] -> :ok _ -> VoiceSuper.start_client(guild, channel, timeout) end end + @doc """ Disconnects from voice in a guild. Returns an error if the connection hadn’t been established. """ - @spec leave(snowflake) :: :ok | {:error, String.t} + @spec leave(snowflake) :: :ok | {:error, String.t()} def leave(guild) do case Registry.lookup(Registry.Voice, {guild, :gateway}) do [] -> {:error, "You're not joined to voice in this guild"} - [{pid, _}|_] -> + + [{pid, _} | _] -> Supervisor.terminate_child(VoiceSuper.Gateway, pid) RateLimiter.change_voice_state(guild, nil) end end + @doc """ Starts playing a music file on a guild's voice connection. @@ -137,11 +140,10 @@ defmodule Alchemy.Voice do Voice.play_file("666", "cool_song.mp3") ``` """ - @spec play_file(snowflake, Path.t, audio_options) :: :ok | {:error, String.t} + @spec play_file(snowflake, Path.t(), audio_options) :: :ok | {:error, String.t()} def play_file(guild, file_path, options \\ []) do - with [{pid, _}|_] <- Registry.lookup(Registry.Voice, {guild, :controller}), - true <- File.exists?(file_path) - do + with [{pid, _} | _] <- Registry.lookup(Registry.Voice, {guild, :controller}), + true <- File.exists?(file_path) do GenServer.call(pid, {:play, file_path, :file, options}) else [] -> {:error, "You're not joined to voice in this guild"} @@ -152,9 +154,10 @@ defmodule Alchemy.Voice do defp play_type(guild, type, data, options) do case Registry.lookup(Registry.Voice, {guild, :controller}) do [] -> {:error, "You're not joined to voice in this guild"} - [{pid, _}|_] -> GenServer.call(pid, {:play, data, type, options}) + [{pid, _} | _] -> GenServer.call(pid, {:play, data, type, options}) end end + @doc """ Starts playing audio from a url. @@ -163,32 +166,36 @@ defmodule Alchemy.Voice do This function does not check the validity of this url, so if it's invalid, an error will get logged, and no audio will be played. """ - @spec play_url(snowflake, String.t, audio_options) :: :ok | {:error, String.t} + @spec play_url(snowflake, String.t(), audio_options) :: :ok | {:error, String.t()} def play_url(guild, url, options \\ []) do play_type(guild, :url, url, options) end + @doc """ Starts playing audio from an `iodata`, or a stream of `iodata`. Similar to `play_url/2` except it doesn't create a stream from `youtube-dl` for you. """ - @spec play_iodata(snowflake, iodata | Enumerable.t, audio_options) :: :ok | {:error, String.t} + @spec play_iodata(snowflake, iodata | Enumerable.t(), audio_options) :: + :ok | {:error, String.t()} def play_iodata(guild, data, options \\ []) do play_type(guild, :iodata, data, options) end + @doc """ Stops playing audio on a guild's voice connection. Returns an error if the connection hadn't been established. """ - @spec stop_audio(snowflake) :: :ok | {:error, String.t} + @spec stop_audio(snowflake) :: :ok | {:error, String.t()} def stop_audio(guild) do case Registry.lookup(Registry.Voice, {guild, :controller}) do [] -> {:error, "You're not joined to voice in this guild"} - [{pid, _}|_] -> GenServer.call(pid, :stop_playing) + [{pid, _} | _] -> GenServer.call(pid, :stop_playing) end end + @doc """ Lets this process listen for the end of an audio track in a guild. @@ -214,13 +221,14 @@ defmodule Alchemy.Voice do end ``` """ - @spec listen_for_end(snowflake) :: :ok | {:error, String.t} + @spec listen_for_end(snowflake) :: :ok | {:error, String.t()} def listen_for_end(guild) do case Registry.lookup(Registry.Voice, {guild, :controller}) do [] -> {:error, "You're not joined to voice in this guild"} - [{pid, _}|_] -> GenServer.call(pid, :add_listener) + [{pid, _} | _] -> GenServer.call(pid, :add_listener) end end + @doc """ Blocks the current process until audio has stopped playing in a guild. @@ -236,15 +244,17 @@ defmodule Alchemy.Voice do end ``` """ - @spec wait_for_end(snowflake, integer | :infinity) :: :ok | {:error, String.t} + @spec wait_for_end(snowflake, integer | :infinity) :: :ok | {:error, String.t()} def wait_for_end(guild, timeout \\ :infinity) do listen_for_end(guild) + receive do {:audio_stopped, ^guild} -> :ok after timeout -> {:error, "Timed out waiting for audio"} end end + @doc """ Returns which channel the client is connected to in a guild. @@ -253,7 +263,7 @@ defmodule Alchemy.Voice do @spec which_channel(snowflake) :: snowflake | nil def which_channel(guild) do case Registry.lookup(Registry.Voice, {guild, :gateway}) do - [{_, channel}|_] -> channel + [{_, channel} | _] -> channel _ -> nil end end diff --git a/lib/Structs/Voice/voice_region.ex b/lib/Structs/Voice/voice_region.ex index a191de8..686d81f 100644 --- a/lib/Structs/Voice/voice_region.ex +++ b/lib/Structs/Voice/voice_region.ex @@ -2,12 +2,5 @@ defmodule Alchemy.VoiceRegion do @moduledoc false @derive Poison.Encoder - defstruct [:id, - :name, - :sample_hostname, - :sample_port, - :vip, - :optimal, - :deprecated, - :custom] + defstruct [:id, :name, :sample_hostname, :sample_port, :vip, :optimal, :deprecated, :custom] end diff --git a/lib/Structs/Voice/voice_state.ex b/lib/Structs/Voice/voice_state.ex index 99b7f08..c449acb 100644 --- a/lib/Structs/Voice/voice_state.ex +++ b/lib/Structs/Voice/voice_state.ex @@ -2,13 +2,15 @@ defmodule Alchemy.VoiceState do @moduledoc false @derive Poison.Encoder - defstruct [:guild_id, - :channel_id, - :user_id, - :session_id, - :deaf, - :mute, - :self_deaf, - :self_mute, - :suppress] + defstruct [ + :guild_id, + :channel_id, + :user_id, + :session_id, + :deaf, + :mute, + :self_deaf, + :self_mute, + :suppress + ] end diff --git a/lib/Structs/permissions.ex b/lib/Structs/permissions.ex index a4f3aba..6cfa052 100644 --- a/lib/Structs/permissions.ex +++ b/lib/Structs/permissions.ex @@ -146,10 +146,12 @@ defmodule Alchemy.Permissions do @spec to_bitset([permission]) :: Integer def to_bitset(list) do @perm_map - |> Enum.reduce(0, + |> Enum.reduce( + 0, fn {k, v}, acc -> if Enum.member?(list, k), do: acc ||| v, else: acc - end) + end + ) end @doc """ diff --git a/lib/Voice/controller.ex b/lib/Voice/controller.ex index 29adb02..af62a7a 100644 --- a/lib/Voice/controller.ex +++ b/lib/Voice/controller.ex @@ -8,28 +8,37 @@ defmodule Alchemy.Voice.Controller do defmodule State do @moduledoc false defstruct [ - :udp, - :key, - :ssrc, - :ip, - :port, - :guild_id, - :player, - :ws, - :kill_timer, - :time, - listeners: MapSet.new()] + :udp, + :key, + :ssrc, + :ip, + :port, + :guild_id, + :player, + :ws, + :kill_timer, + :time, + listeners: MapSet.new() + ] end def start_link(udp, key, ssrc, ip, port, guild_id, me) do - state = %State{udp: udp, key: key, ssrc: ssrc, - ip: ip, port: port, guild_id: guild_id, ws: me, time: 0} - GenServer.start_link(__MODULE__, state, - name: VoiceRegistry.via({guild_id, :controller})) + state = %State{ + udp: udp, + key: key, + ssrc: ssrc, + ip: ip, + port: port, + guild_id: guild_id, + ws: me, + time: 0 + } + + GenServer.start_link(__MODULE__, state, name: VoiceRegistry.via({guild_id, :controller})) end def init(state) do - Logger.debug "Voice Controller for #{state.guild_id} started" + Logger.debug("Voice Controller for #{state.guild_id} started") {:ok, state} end @@ -39,35 +48,46 @@ defmodule Alchemy.Voice.Controller do unless state.kill_timer == nil do Process.cancel_timer(state.kill_timer) end + timer = Process.send_after(self(), :stop_playing, 120) :gen_udp.send(state.udp, state.ip, state.port, data) {:noreply, %{state | kill_timer: timer}} end - + def handle_cast({:update_time, new_time}, state) do {:noreply, %{state | time: new_time}} end def handle_call({:play, path, type, options}, _, state) do self = self() + if state.player != nil && Process.alive?(state.player.pid) do {:reply, {:error, "Already playing audio"}, state} else - player = Task.async(fn -> - new_time = run_player(path, type, options, self, - %{ssrc: state.ssrc, key: state.key, ws: state.ws, time: state.time}) - - GenServer.cast(self, {:update_time, new_time}) - end) + player = + Task.async(fn -> + new_time = + run_player(path, type, options, self, %{ + ssrc: state.ssrc, + key: state.key, + ws: state.ws, + time: state.time + }) + + GenServer.cast(self, {:update_time, new_time}) + end) + {:reply, :ok, %{state | player: player}} end end def handle_call(:stop_playing, _, state) do - new = case state.player do - nil -> state - _ -> stop_playing(state) - end + new = + case state.player do + nil -> state + _ -> stop_playing(state) + end + {:reply, :ok, new} end @@ -86,8 +106,10 @@ defmodule Alchemy.Voice.Controller do defp stop_playing(state) do Task.shutdown(state.player) + MapSet.to_list(state.listeners) |> Enum.each(&send(&1, {:audio_stopped, state.guild_id})) + %{state | listeners: MapSet.new()} end @@ -99,41 +121,94 @@ defmodule Alchemy.Voice.Controller do defp mk_stream(file_path, options) do volume = (options[:vol] || 100) / 100 + %Proc{out: audio_stream} = - Porcelain.spawn(Application.fetch_env!(:alchemy, :ffmpeg_path), - ["-hide_banner", "-loglevel", "quiet", "-i","#{file_path}", - "-f", "data", "-map", "0:a", "-ar", "48k", "-ac", "2", - "-af", "volume=#{volume}", - "-acodec", "libopus", "-b:a", "128k", "pipe:1"], [out: :stream]) + Porcelain.spawn( + Application.fetch_env!(:alchemy, :ffmpeg_path), + [ + "-hide_banner", + "-loglevel", + "quiet", + "-i", + "#{file_path}", + "-f", + "data", + "-map", + "0:a", + "-ar", + "48k", + "-ac", + "2", + "-af", + "volume=#{volume}", + "-acodec", + "libopus", + "-b:a", + "128k", + "pipe:1" + ], + out: :stream + ) + audio_stream end defp url_stream(url, options) do %Proc{out: youtube} = - Porcelain.spawn(Application.fetch_env!(:alchemy, :youtube_dl_path), - ["-q", "-f", "bestaudio", "-o", "-", url], [out: :stream]) + Porcelain.spawn( + Application.fetch_env!(:alchemy, :youtube_dl_path), + ["-q", "-f", "bestaudio", "-o", "-", url], + out: :stream + ) + io_data_stream(youtube, options) end defp io_data_stream(data, options) do volume = (options[:vol] || 100) / 100 - opts = [in: data, out: :stream] + opts = [in: data, out: :stream] + %Proc{out: audio_stream} = - Porcelain.spawn(Application.fetch_env!(:alchemy, :ffmpeg_path), - ["-hide_banner", "-loglevel", "quiet", "-i","pipe:0", - "-f", "data", "-map", "0:a", "-ar", "48k", "-ac", "2", - "-af", "volume=#{volume}", - "-acodec", "libopus", "-b:a", "128k", "pipe:1"], opts) + Porcelain.spawn( + Application.fetch_env!(:alchemy, :ffmpeg_path), + [ + "-hide_banner", + "-loglevel", + "quiet", + "-i", + "pipe:0", + "-f", + "data", + "-map", + "0:a", + "-ar", + "48k", + "-ac", + "2", + "-af", + "volume=#{volume}", + "-acodec", + "libopus", + "-b:a", + "128k", + "pipe:1" + ], + opts + ) + audio_stream - end + end defp run_player(path, type, options, parent, state) do send(state.ws, {:speaking, true}) - stream = case type do - :file -> mk_stream(path, options) - :url -> url_stream(path, options) - :iodata -> io_data_stream(path, options) - end + + stream = + case type do + :file -> mk_stream(path, options) + :url -> url_stream(path, options) + :iodata -> io_data_stream(path, options) + end + {seq, time, _} = stream |> Enum.reduce({0, state.time, nil}, fn packet, {seq, time, elapsed} -> @@ -144,20 +219,22 @@ defmodule Alchemy.Voice.Controller do Process.sleep(do_sleep(elapsed)) {seq + 1, time + 960, elapsed + 20} end) + # We must send 5 frames of silence to end opus interpolation - {_seq, new_time} = Enum.reduce(1..5, {seq, time}, fn _, {seq, time} -> - GenServer.cast(parent, {:send_audio, <<0xF8, 0xFF, 0xFE>>}) - Process.sleep(20) - {seq + 1, time + 960} - end) - + {_seq, new_time} = + Enum.reduce(1..5, {seq, time}, fn _, {seq, time} -> + GenServer.cast(parent, {:send_audio, <<0xF8, 0xFF, 0xFE>>}) + Process.sleep(20) + {seq + 1, time + 960} + end) + send(state.ws, {:speaking, false}) new_time end defp mk_audio(packet, seq, time, state) do header = header(seq, time, state.ssrc) - nonce = (header <> <<0::size(96)>>) + nonce = header <> <<0::size(96)>> header <> Kcl.secretbox(packet, nonce, state.key) end diff --git a/lib/Voice/supervisor.ex b/lib/Voice/supervisor.ex index 7fa7148..600d3ee 100644 --- a/lib/Voice/supervisor.ex +++ b/lib/Voice/supervisor.ex @@ -63,6 +63,7 @@ defmodule Alchemy.Voice.Supervisor do case Map.get(state, guild) do nil -> {:reply, :ok, Map.put(state, guild, pid)} + _ -> {:reply, {:error, "Already joining this guild"}, state} end @@ -77,15 +78,17 @@ defmodule Alchemy.Voice.Supervisor do nil -> nil pid -> send(pid, data) end + {:noreply, state} end end def start_client(guild, channel, timeout) do - r = with :ok <- GenServer.call(Server, {:start_client, guild}), - [] <- Registry.lookup(Registry.Voice, {guild, :gateway}) - do + r = + with :ok <- GenServer.call(Server, {:start_client, guild}), + [] <- Registry.lookup(Registry.Voice, {guild, :gateway}) do RateLimiter.change_voice_state(guild, channel) + recv = fn -> receive do x -> {:ok, x} @@ -93,19 +96,23 @@ defmodule Alchemy.Voice.Supervisor do div(timeout, 2) -> {:error, "Timed out"} end end + with {:ok, {user_id, session}} <- recv.(), {:ok, {token, url}} <- recv.(), - {:ok, _pid1} <- Supervisor.start_child(Gateway, - [url, token, session, user_id, guild, channel]), - {:ok, _pid2} <- recv.() - do + {:ok, _pid1} <- + Supervisor.start_child( + Gateway, + [url, token, session, user_id, guild, channel] + ), + {:ok, _pid2} <- recv.() do :ok end - else - [{_pid, _}|_] -> - RateLimiter.change_voice_state(guild, channel) - :ok - end + else + [{_pid, _} | _] -> + RateLimiter.change_voice_state(guild, channel) + :ok + end + GenServer.call(Server, {:client_done, guild}) r end diff --git a/lib/Voice/udp.ex b/lib/Voice/udp.ex index 0302c59..a47bf37 100644 --- a/lib/Voice/udp.ex +++ b/lib/Voice/udp.ex @@ -3,14 +3,15 @@ defmodule Alchemy.Voice.UDP do def open_udp(endpoint, port, ssrc) do {:ok, discord_ip} = :inet.parse_address(to_charlist(endpoint)) - data = <> + data = <> udp_opts = [:binary, active: false, reuseaddr: true] {:ok, udp} = :gen_udp.open(0, udp_opts) :gen_udp.send(udp, discord_ip, port, data) {:ok, discovery} = :gen_udp.recv(udp, 70) - <<_padding :: size(32), my_ip :: bitstring-size(112), - _null :: size(400), my_port :: size(16)>> = - discovery |> Tuple.to_list |> List.last + + <<_padding::size(32), my_ip::bitstring-size(112), _null::size(400), my_port::size(16)>> = + discovery |> Tuple.to_list() |> List.last() + {my_ip, my_port, discord_ip, udp} end end diff --git a/test/Structs/Guild/guild_test.exs b/test/Structs/Guild/guild_test.exs index a2659a6..86417b8 100644 --- a/test/Structs/Guild/guild_test.exs +++ b/test/Structs/Guild/guild_test.exs @@ -20,7 +20,9 @@ defmodule AlchemyTest.Structs.Guild.GuildTest do "features" => [], "mfa_level" => 0 } + with_icon = Map.put(without_icon, "icon", "ababababa") + %{ guild_without_icon: Guild.from_map(without_icon), guild_with_icon: Guild.from_map(with_icon) diff --git a/test/Voice/supervisor_test.exs b/test/Voice/supervisor_test.exs index ad3fed6..c4d12e2 100644 --- a/test/Voice/supervisor_test.exs +++ b/test/Voice/supervisor_test.exs @@ -1,20 +1,37 @@ defmodule Alchemy.Voice.SupervisorTest do use ExUnit.Case, async: true - alias Alchemy.Voice.Supervisor + alias Alchemy.Voice.Supervisor, as: VoiceSupervisor setup do - {:ok, supervisor} = Supervisor.start_link() - {:ok, supervisor: supervisor} + pid = + case Process.whereis(VoiceSupervisor) do + nil -> + {:ok, pid} = VoiceSupervisor.start_link() + pid + + pid -> + pid + end + + {:ok, supervisor: pid} end - test "re-registering doesn't work" do - assert GenServer.call(Supervisor.Server, {:start_client, 1}) == :ok - bad_resp = GenServer.call(Supervisor.Server, {:start_client, 1}) + test "re-registering doesn't work", %{supervisor: supervisor} do + assert GenServer.call(VoiceSupervisor.Server, {:start_client, 1}) == :ok + bad_resp = GenServer.call(VoiceSupervisor.Server, {:start_client, 1}) refute bad_resp == :ok + + cleanup(supervisor) + end + + test "different channels do work", %{supervisor: supervisor} do + assert GenServer.call(VoiceSupervisor.Server, {:start_client, 3}) == :ok + assert GenServer.call(VoiceSupervisor.Server, {:start_client, 4}) == :ok + + cleanup(supervisor) end - test "different channels do work" do - assert GenServer.call(Supervisor.Server, {:start_client, 3}) == :ok - assert GenServer.call(Supervisor.Server, {:start_client, 4}) == :ok + defp cleanup(supervisor) do + Supervisor.stop(supervisor) end end