Rename to aether plugin and add /aether timeline integration

This commit is contained in:
2026-04-04 20:33:51 +02:00
parent 647d5537ff
commit 78f6c11b30
14 changed files with 515 additions and 62 deletions

View File

@@ -1,13 +1,13 @@
/* /*
* Plugin CSS entry point. * Plugin CSS entry point.
* *
* Served at /plugins-assets/my_plugin/my_plugin.css * Served at /plugins-assets/aether/aether.css
* and included in the page layout if declared in manifest.json assets.global_css. * and included in the page layout if declared in manifest.json assets.global_css.
* *
* Prefix all selectors with your plugin name to avoid collisions * Prefix all selectors with your plugin name to avoid collisions
* with host or other plugin styles. * with host or other plugin styles.
*/ */
.my-plugin { .aether {
/* Plugin-scoped styles go here */ /* Plugin-scoped styles go here */
} }

View File

@@ -1,15 +1,15 @@
// Plugin JavaScript entry point. // Plugin JavaScript entry point.
// //
// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js // This file is served by the host at /plugins-assets/aether/aether.js
// and included in the page layout if declared in manifest.json assets.global_js. // and included in the page layout if declared in manifest.json assets.global_js.
// //
// To register LiveView hooks: // To register LiveView hooks:
// //
// window.TribesPluginHooks = window.TribesPluginHooks || {}; // window.TribesPluginHooks = window.TribesPluginHooks || {};
// window.TribesPluginHooks["MyPluginHook"] = { // window.TribesPluginHooks["AetherHook"] = {
// mounted() { // mounted() {
// console.log("MyPlugin hook mounted"); // console.log("Aether hook mounted");
// } // }
// }; // };
console.log("my_plugin loaded"); console.log("aether loaded");

View File

@@ -1,4 +1,4 @@
defmodule MyPlugin.Application do defmodule Aether.Application do
@moduledoc """ @moduledoc """
OTP Application for this plugin. OTP Application for this plugin.
@@ -12,10 +12,10 @@ defmodule MyPlugin.Application do
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
# Add your supervised processes here, e.g.: # Add your supervised processes here, e.g.:
# {MyPlugin.Worker, []} # {Aether.Worker, []}
] ]
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor] opts = [strategy: :one_for_one, name: Aether.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
end end
end end

View File

@@ -1,4 +1,4 @@
defmodule MyPlugin.Plugin do defmodule Aether.Plugin do
@moduledoc """ @moduledoc """
Tribes plugin entry point. Tribes plugin entry point.
@@ -10,7 +10,7 @@ defmodule MyPlugin.Plugin do
# Once Tribes.Plugin.Base is available, replace the manual implementation # Once Tribes.Plugin.Base is available, replace the manual implementation
# with: # with:
# #
# use Tribes.Plugin.Base, otp_app: :my_plugin # use Tribes.Plugin.Base, otp_app: :aether
# #
# and override only register/1. # and override only register/1.
@@ -27,8 +27,8 @@ defmodule MyPlugin.Plugin do
enhances_with: manifest["enhances_with"] || [], enhances_with: manifest["enhances_with"] || [],
nav_items: [ nav_items: [
%{ %{
label: "My Plugin", label: "Aether",
path: "/plugins/my_plugin", path: "/aether",
icon: nil, icon: nil,
requires: [], requires: [],
order: 50 order: 50
@@ -36,8 +36,8 @@ defmodule MyPlugin.Plugin do
], ],
pages: [ pages: [
%{ %{
path: "/plugins/my_plugin", path: "/aether",
live_view: MyPluginWeb.HomeLive, live_view: AetherWeb.TimelineLive,
layout: nil layout: nil
} }
], ],
@@ -58,24 +58,44 @@ defmodule MyPlugin.Plugin do
end end
defp manifest_path do defp manifest_path do
# In a release, manifest.json sits alongside ebin/ in the plugin directory. project_manifest = Path.join(__DIR__, "../../manifest.json") |> Path.expand()
# In dev mode (path dep), it's at the project root.
case :code.priv_dir(:my_plugin) do
{:error, :bad_name} ->
# Dev mode fallback: relative to project root
Path.join(__DIR__, "../../../manifest.json") |> Path.expand()
priv_dir -> candidates =
priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand() case :code.priv_dir(:aether) do
end {:error, :bad_name} ->
[project_manifest]
priv_dir ->
priv_dir = to_string(priv_dir)
[
Path.join(priv_dir, "../manifest.json") |> Path.expand(),
project_manifest
]
end
first_existing_path(candidates) || project_manifest
end end
defp migrations_path(manifest) do defp migrations_path(manifest) do
if manifest["migrations"] do if manifest["migrations"] do
case :code.priv_dir(:my_plugin) do candidates =
{:error, :bad_name} -> nil case :code.priv_dir(:aether) do
priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations") {:error, :bad_name} ->
end [Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()]
priv_dir ->
[
Path.join(to_string(priv_dir), "repo/migrations") |> Path.expand(),
Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()
]
end
first_existing_path(candidates)
end end
end end
defp first_existing_path(paths) do
Enum.find(paths, &File.exists?/1)
end
end end

View File

@@ -1,9 +1,9 @@
defmodule MyPluginWeb.HomeLive do defmodule AetherWeb.HomeLive do
@moduledoc """ @moduledoc """
Example LiveView page for the plugin. Example LiveView page for the plugin.
This page is registered in the plugin spec and mounted by the host This page is registered in the plugin spec and mounted by the host
at /plugins/my_plugin. at /plugins/aether.
""" """
# In dev mode (plugin loaded as path dep), you can use host macros: # In dev mode (plugin loaded as path dep), you can use host macros:
@@ -22,7 +22,7 @@ defmodule MyPluginWeb.HomeLive do
<h1 class="text-2xl font-bold">My Plugin</h1> <h1 class="text-2xl font-bold">My Plugin</h1>
<p class="mt-2 text-base-content/70"> <p class="mt-2 text-base-content/70">
This is a Tribes plugin. Edit this page in This is a Tribes plugin. Edit this page in
<code>lib/my_plugin_web/live/home_live.ex</code>. <code>lib/aether_web/live/home_live.ex</code>.
</p> </p>
</div> </div>
""" """

View File

@@ -0,0 +1,366 @@
defmodule AetherWeb.TimelineLive do
use TribesWeb, :live_view
on_mount({TribesWeb.LiveUserAuth, :live_user_optional})
alias Tribes.Communities
alias Tribes.Communities.Tribe
alias Tribes.Nostr
@impl true
def mount(params, session, socket) do
tribe_id = resolve_tribe_id(params, session)
scope = load_scope(socket.assigns.current_user, tribe_id)
{:ok, notes} = Nostr.list_notes(limit: 100)
notes = filter_notes(notes, scope)
profile_cache = load_profile_cache(notes)
note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new()
socket =
socket
|> assign(:page_title, gettext("Aether"))
|> assign(:current_scope, socket.assigns[:current_scope])
|> assign(:tribe_id, tribe_id)
|> assign(:tribe, scope.tribe)
|> assign(:can_view_tribe?, scope.can_view?)
|> assign(:is_tribe_member?, scope.member?)
|> assign(:tribe_member_pubkeys, scope.member_pubkeys)
|> assign(:profile_cache, profile_cache)
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> assign(:form, to_form(%{"content" => ""}, as: :note))
|> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join))
|> assign(:subscription_ref, nil)
|> stream_configure(:notes, dom_id: &("note-" <> &1["id"]))
|> stream(:notes, notes)
if connected?(socket) do
{:ok, ref} = Nostr.subscribe_notes(self(), "aether", limit: 100)
{:ok, assign(socket, :subscription_ref, ref)}
else
{:ok, socket}
end
end
@impl true
def handle_event("join_tribe", %{"join" => %{"invite_code" => invite_code}}, socket) do
cond do
is_nil(socket.assigns.current_user) ->
{:noreply, put_flash(socket, :error, gettext("Sign in to join tribes"))}
is_nil(socket.assigns.tribe) ->
{:noreply, put_flash(socket, :error, gettext("No tribe selected"))}
true ->
opts =
case String.trim(invite_code || "") do
"" -> []
code -> [invite_code: code]
end
case Communities.join_tribe(socket.assigns.current_user, socket.assigns.tribe.id, opts) do
{:ok, _membership} ->
socket = refresh_scope(socket)
{:noreply,
socket
|> put_flash(:info, gettext("Joined tribe"))
|> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join))}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Unable to join tribe"))}
end
end
end
def handle_event("post", %{"note" => %{"content" => content}}, socket) do
content = String.trim(content)
cond do
socket.assigns[:current_user] == nil ->
{:noreply, put_flash(socket, :error, gettext("Sign in to post"))}
content == "" ->
{:noreply, put_flash(socket, :error, gettext("Post cannot be empty"))}
not can_post_to_scope?(socket) ->
{:noreply, put_flash(socket, :error, gettext("Join this tribe to post"))}
true ->
case socket.assigns[:session_privkey] do
privkey when is_binary(privkey) ->
case Nostr.publish_note(content, socket.assigns.current_user.pubkey_hex, privkey) do
{:ok, event} ->
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
note_ids = MapSet.put(socket.assigns.note_ids, event["id"])
{:noreply,
socket
|> assign(:form, to_form(%{"content" => ""}, as: :note))
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> stream_insert(:notes, event, at: 0)}
else
{:noreply,
socket
|> assign(:form, to_form(%{"content" => ""}, as: :note))
|> put_flash(:info, gettext("Posted outside current tribe scope"))}
end
{:error, reason} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to publish: %{reason}", reason: inspect(reason))
)}
end
_other ->
{:noreply, put_flash(socket, :error, gettext("Signing key unavailable in session"))}
end
end
end
@impl true
def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = event}, socket) do
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) and
socket.assigns.can_view_tribe? do
profile_cache = maybe_put_profile(socket.assigns.profile_cache, event["pubkey"])
if MapSet.member?(socket.assigns.note_ids, event["id"]) do
{:noreply, assign(socket, :profile_cache, profile_cache)}
else
note_ids = MapSet.put(socket.assigns.note_ids, event["id"])
{:noreply,
socket
|> assign(:profile_cache, profile_cache)
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> stream_insert(:notes, event, at: 0)}
end
else
{:noreply, socket}
end
end
def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :eose, _ref, _sub_id}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :closed, _ref, _sub_id, _reason}, socket), do: {:noreply, socket}
@impl true
def terminate(_reason, socket) do
if is_reference(socket.assigns[:subscription_ref]) do
Nostr.unsubscribe(socket.assigns.subscription_ref)
end
:ok
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} current_user={@current_user}>
<div class="space-y-6" id="aether-page">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">{timeline_title(@tribe)}</h1>
<div class="badge badge-outline">{gettext("%{count} notes", count: @note_count)}</div>
</div>
<%= if @tribe && !@is_tribe_member? do %>
<div class="card border border-base-300 bg-base-100 shadow-sm" id="tribe-join-card">
<div class="card-body gap-3">
<p class="text-sm text-base-content/80">
{gettext("You are viewing this tribe as a guest.")}
</p>
<.form for={@join_form} id="join-tribe-form" phx-submit="join_tribe" class="space-y-3">
<.input
field={@join_form[:invite_code]}
type="text"
label={gettext("Invite code (optional for public tribes)")}
/>
<div class="card-actions justify-end">
<button id="join-tribe-button" type="submit" class="btn btn-primary btn-sm">
{gettext("Join tribe")}
</button>
</div>
</.form>
</div>
</div>
<% end %>
<%= if @current_user && (@can_view_tribe? || is_nil(@tribe)) do %>
<.form for={@form} id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
<div class="card-body">
<.input field={@form[:content]} type="textarea" label={gettext("Post")} rows="3" />
<div class="card-actions justify-end">
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
{gettext("Post note")}
</button>
</div>
</div>
</.form>
<% end %>
<%= if !@can_view_tribe? && @tribe do %>
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
{gettext("This tribe timeline is private until you join.")}
</div>
<% end %>
<div id="notes" phx-update="stream" class="space-y-3">
<div
id="notes-empty"
class="hidden only:block rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-sm text-base-content/60"
>
{gettext("No notes yet.")}
</div>
<article :for={{id, note} <- @streams.notes} id={id} class="card bg-base-100 shadow-sm">
<div class="card-body gap-2">
<div class="flex items-center justify-between gap-4 text-xs text-base-content/60">
<.link navigate={~p"/u/#{note["pubkey"]}"} class="link link-hover font-medium">
{author_label(@profile_cache, note["pubkey"])}
</.link>
<span
phx-hook="DateTime"
id={"time-#{note["id"]}"}
phx-update="ignore"
data-timestamp={note["created_at"] * 1000}
data-relative="true"
data-format="LLL"
>
</span>
</div>
<p class="whitespace-pre-wrap text-sm leading-6">{note["content"]}</p>
</div>
</article>
</div>
</div>
</Layouts.app>
"""
end
defp resolve_tribe_id(params, session) when is_map(params) do
case Map.get(params, "tribe_id") || Map.get(params, "id") do
tribe_id when is_binary(tribe_id) and tribe_id != "" ->
tribe_id
_ ->
parse_tribe_id_from_plugin_path(session["plugin_path"])
end
end
defp resolve_tribe_id(_params, session),
do: parse_tribe_id_from_plugin_path(session["plugin_path"])
defp parse_tribe_id_from_plugin_path(path) when is_binary(path) do
case String.split(path, "/", trim: true) do
["aether", tribe_id | _] -> tribe_id
_ -> nil
end
end
defp parse_tribe_id_from_plugin_path(_path), do: nil
defp load_scope(current_user, tribe_id) when is_binary(tribe_id) do
with {:ok, %Tribe{} = tribe} <- Communities.get_tribe(tribe_id),
{:ok, members} <- Communities.list_tribe_members_for_timeline(tribe.id) do
member_pubkeys = members |> Enum.map(& &1.user.pubkey_hex) |> MapSet.new()
is_member =
case current_user do
nil -> false
user -> Enum.any?(members, &(&1.user_id == user.id))
end
can_view? = tribe.visibility == :public || is_member
%{tribe: tribe, member_pubkeys: member_pubkeys, member?: is_member, can_view?: can_view?}
else
_ -> %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true}
end
end
defp load_scope(_current_user, _tribe_id),
do: %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true}
defp refresh_scope(socket) do
scope = load_scope(socket.assigns.current_user, socket.assigns.tribe_id)
notes =
case Nostr.list_notes(limit: 100) do
{:ok, notes} -> filter_notes(notes, scope)
_ -> []
end
note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new()
socket
|> assign(:tribe, scope.tribe)
|> assign(:can_view_tribe?, scope.can_view?)
|> assign(:is_tribe_member?, scope.member?)
|> assign(:tribe_member_pubkeys, scope.member_pubkeys)
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> stream(:notes, notes, reset: true)
end
defp can_post_to_scope?(socket) do
case socket.assigns.tribe_member_pubkeys do
nil -> true
pubkeys -> MapSet.member?(pubkeys, socket.assigns.current_user.pubkey_hex)
end
end
defp note_in_scope?(_event, nil), do: true
defp note_in_scope?(event, member_pubkeys) do
MapSet.member?(member_pubkeys, event["pubkey"])
end
defp filter_notes(_notes, %{can_view?: false}), do: []
defp filter_notes(notes, %{member_pubkeys: nil}), do: notes
defp filter_notes(notes, %{member_pubkeys: member_pubkeys}) do
Enum.filter(notes, &note_in_scope?(&1, member_pubkeys))
end
defp load_profile_cache(notes) do
notes
|> Enum.map(& &1["pubkey"])
|> Enum.uniq()
|> Enum.reduce(%{}, &maybe_put_profile(&2, &1))
end
defp maybe_put_profile(cache, pubkey) do
if Map.has_key?(cache, pubkey) do
cache
else
case Nostr.get_profile(pubkey) do
{:ok, nil} -> Map.put(cache, pubkey, nil)
{:ok, event} -> Map.put(cache, pubkey, parse_profile(event))
{:error, _reason} -> Map.put(cache, pubkey, nil)
end
end
end
defp parse_profile(%{"content" => content}) when is_binary(content) do
case JSON.decode(content) do
{:ok, profile} when is_map(profile) -> profile
_other -> %{}
end
end
defp parse_profile(_event), do: %{}
defp author_label(profile_cache, pubkey) do
profile = Map.get(profile_cache, pubkey, %{}) || %{}
profile["name"] || "#{String.slice(pubkey, 0, 8)}"
end
defp timeline_title(nil), do: gettext("Global timeline")
defp timeline_title(%Tribe{name: name}), do: gettext("%{name} timeline", name: name)
end

View File

@@ -0,0 +1,5 @@
defmodule Tribes.Plugins.Aether.Plugin do
@moduledoc false
defdelegate register(context), to: Aether.Plugin
end

View File

@@ -1,15 +1,16 @@
{ {
"name": "my_plugin", "name": "aether",
"version": "0.1.0", "version": "0.1.0",
"description": "TODO: Describe what this plugin does", "description": "TODO: Describe what this plugin does",
"entry_module": "MyPlugin.Plugin", "entry_module": "Tribes.Plugins.Aether.Plugin",
"host_api": "1", "host_api": "1",
"provides": [], "otp_app": "aether",
"requires": ["ecto@1"], "provides": ["timeline_ui@1"],
"requires": ["ecto@1", "phoenix@1"],
"enhances_with": [], "enhances_with": [],
"assets": { "assets": {
"global_js": ["my_plugin.js"], "global_js": ["aether.js"],
"global_css": ["my_plugin.css"] "global_css": ["aether.css"]
}, },
"migrations": false, "migrations": false,
"children": false "children": false

View File

@@ -1,9 +1,9 @@
defmodule MyPlugin.MixProject do defmodule Aether.MixProject do
use Mix.Project use Mix.Project
def project do def project do
[ [
app: :my_plugin, app: :aether,
version: "0.1.0", version: "0.1.0",
elixir: "~> 1.18", elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
@@ -17,7 +17,7 @@ defmodule MyPlugin.MixProject do
[ [
extra_applications: [:logger] extra_applications: [:logger]
# Uncomment if your plugin needs its own supervision tree: # Uncomment if your plugin needs its own supervision tree:
# mod: {MyPlugin.Application, []} # mod: {Aether.Application, []}
] ]
end end
@@ -29,7 +29,7 @@ defmodule MyPlugin.MixProject do
# Host dependency — provides Tribes.Plugin behaviour, types, and test helpers. # Host dependency — provides Tribes.Plugin behaviour, types, and test helpers.
# #
# For local development alongside a tribes checkout: # For local development alongside a tribes checkout:
{:tribes, path: "../tribes", only: [:dev, :test]}, {:tribes, path: "../tribes", runtime: false},
# #
# For CI or standalone development (when not co-located with tribes): # For CI or standalone development (when not co-located with tribes):
# {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]}, # {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]},

View File

@@ -45,13 +45,32 @@ if [ -d "test/my_plugin" ]; then
mv "test/my_plugin" "test/$SNAKE" mv "test/my_plugin" "test/$SNAKE"
fi fi
# Replace in all text files # Replace in all text files (portable across GNU/BSD sed)
find . -type f \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) -exec \ sed_in_place() {
sed -i '' \ file=$1
-e "s/my_plugin/$SNAKE/g" \
-e "s/MyPlugin/$MODULE/g" \ if sed --version >/dev/null 2>&1; then
-e "s/my-plugin/$SNAKE/g" \ sed -i \
{} + -e "s/my_plugin/$SNAKE/g" \
-e "s/MyPlugin/$MODULE/g" \
-e "s/my-plugin/$SNAKE/g" \
"$file"
else
sed -i '' \
-e "s/my_plugin/$SNAKE/g" \
-e "s/MyPlugin/$MODULE/g" \
-e "s/my-plugin/$SNAKE/g" \
"$file"
fi
}
while IFS= read -r -d '' file; do
sed_in_place "$file"
done < <(
find . -type f \
\( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \
-print0
)
# Rename asset files # Rename asset files
for ext in js css; do for ext in js css; do

View File

@@ -0,0 +1,27 @@
defmodule Aether.HostIntegrationTest do
use ExUnit.Case, async: false
setup do
start_supervised!({Tribes.PluginRegistry, []})
spec = Tribes.Plugins.Aether.Plugin.register(%{pubsub: nil, repo: nil})
:ok = Tribes.PluginRegistry.register_plugin(spec.name, spec, "/tmp/aether")
on_exit(fn ->
:ok = Tribes.PluginRegistry.unregister_plugin(spec.name)
end)
%{spec: spec}
end
test "registers timeline capability and /aether route with host", %{spec: spec} do
assert spec.name == "aether"
assert %{name: "aether"} = Tribes.PluginRegistry.provider!("timeline_ui@1")
assert {:ok, "aether", %{path: "/aether"}} =
Tribes.PluginRegistry.page_for_path("/aether/tribe-123")
assert TribesWeb.Navigation.timeline_base_path() == "/aether"
assert TribesWeb.Navigation.timeline_path("tribe-123") == "/aether/tribe-123"
end
end

View File

@@ -1,4 +1,4 @@
defmodule MyPlugin.ManifestTest do defmodule Aether.ManifestTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
@manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand() @manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand()
@@ -15,7 +15,15 @@ defmodule MyPlugin.ManifestTest do
end end
test "has required fields", %{manifest: manifest} do test "has required fields", %{manifest: manifest} do
required = ["name", "version", "entry_module", "host_api", "provides", "requires"] required = [
"name",
"version",
"entry_module",
"host_api",
"otp_app",
"provides",
"requires"
]
for field <- required do for field <- required do
assert Map.has_key?(manifest, field), assert Map.has_key?(manifest, field),
@@ -24,13 +32,18 @@ defmodule MyPlugin.ManifestTest do
end end
test "name matches OTP app name", %{manifest: manifest} do test "name matches OTP app name", %{manifest: manifest} do
assert manifest["name"] == "my_plugin" assert manifest["name"] == "aether"
assert manifest["otp_app"] == manifest["name"]
end end
test "entry_module is a valid Elixir module name", %{manifest: manifest} do test "entry_module uses Tribes.Plugins namespace and Plugin suffix", %{manifest: manifest} do
module_name = manifest["entry_module"] module_name = manifest["entry_module"]
assert is_binary(module_name) assert is_binary(module_name)
assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ")
assert Regex.match?(
~r/^Tribes\.Plugins\.[A-Z][A-Za-z0-9_]*(\.[A-Z][A-Za-z0-9_]*)*\.Plugin$/,
module_name
)
end end
test "provides contains valid capability identifiers", %{manifest: manifest} do test "provides contains valid capability identifiers", %{manifest: manifest} do
@@ -53,7 +66,9 @@ defmodule MyPlugin.ManifestTest do
test "entry_module matches actual plugin module", %{manifest: manifest} do test "entry_module matches actual plugin module", %{manifest: manifest} do
module = String.to_atom("Elixir.#{manifest["entry_module"]}") module = String.to_atom("Elixir.#{manifest["entry_module"]}")
assert Code.ensure_loaded?(module), "entry_module #{manifest["entry_module"]} must be loadable"
assert Code.ensure_loaded?(module),
"entry_module #{manifest["entry_module"]} must be loadable"
end end
end end
end end

View File

@@ -1,14 +1,14 @@
defmodule MyPlugin.PluginTest do defmodule Aether.PluginTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
describe "register/1" do describe "register/1" do
setup do setup do
context = %{pubsub: nil, repo: nil} context = %{pubsub: nil, repo: nil}
%{spec: MyPlugin.Plugin.register(context)} %{spec: Aether.Plugin.register(context)}
end end
test "returns plugin name and version", %{spec: spec} do test "returns plugin name and version", %{spec: spec} do
assert spec.name == "my_plugin" assert spec.name == "aether"
assert is_binary(spec.version) assert is_binary(spec.version)
end end

View File

@@ -1,4 +1,4 @@
defmodule MyPlugin.ContractTest do defmodule Aether.ContractTest do
@moduledoc """ @moduledoc """
Contract compliance tests. Contract compliance tests.
@@ -6,10 +6,10 @@ defmodule MyPlugin.ContractTest do
When Tribes.Plugin.ContractTest is available (host dep loaded), When Tribes.Plugin.ContractTest is available (host dep loaded),
replace this file with: replace this file with:
defmodule MyPlugin.ContractTest do defmodule Aether.ContractTest do
use Tribes.Plugin.ContractTest, use Tribes.Plugin.ContractTest,
plugin: MyPlugin.Plugin, plugin: Tribes.Plugins.Aether.Plugin,
otp_app: :my_plugin otp_app: :aether
end end
Until then, this file provides a standalone equivalent. Until then, this file provides a standalone equivalent.
@@ -17,7 +17,7 @@ defmodule MyPlugin.ContractTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
@plugin MyPlugin.Plugin @plugin Tribes.Plugins.Aether.Plugin
@manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand() @manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand()
setup_all do setup_all do