You've already forked tribes-plugin-aether
forked from tribes/tribes-plugin-template
skeleton
This commit is contained in:
3
.formatter.exs
Normal file
3
.formatter.exs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
71
.github/workflows/ci.yml
vendored
Normal file
71
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
MIX_ENV: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: erlef/setup-beam@v1
|
||||||
|
with:
|
||||||
|
elixir-version: "1.19"
|
||||||
|
otp-version: "27"
|
||||||
|
|
||||||
|
- name: Cache deps
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
deps
|
||||||
|
_build
|
||||||
|
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-mix-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: mix deps.get
|
||||||
|
|
||||||
|
- name: Compile (warnings as errors)
|
||||||
|
run: mix compile --warnings-as-errors
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: mix format --check-formatted
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: mix test
|
||||||
|
|
||||||
|
- name: Validate manifest
|
||||||
|
run: |
|
||||||
|
elixir -e '
|
||||||
|
manifest = "manifest.json" |> File.read!() |> Jason.decode!()
|
||||||
|
required = ["name", "version", "entry_module", "host_api", "provides", "requires"]
|
||||||
|
missing = Enum.filter(required, &(not Map.has_key?(manifest, &1)))
|
||||||
|
if missing != [] do
|
||||||
|
IO.puts(:stderr, "Missing manifest fields: #{inspect(missing)}")
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
|
IO.puts("Plugin: #{manifest["name"]} v#{manifest["version"]}")
|
||||||
|
'
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Dependencies and build
|
||||||
|
/deps/
|
||||||
|
/_build/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules/
|
||||||
|
/assets/node_modules/
|
||||||
|
|
||||||
|
# Generated on crash
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Editor and OS
|
||||||
|
.elixir_ls/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Tribes Plugin Template
|
||||||
|
|
||||||
|
Template for creating [Tribes](https://github.com/your-org/tribes) plugins.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Click **"Use this template"** on GitHub to create your own repo
|
||||||
|
2. Clone and rename:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/you/your-plugin.git
|
||||||
|
cd your-plugin
|
||||||
|
./scripts/rename.sh your_plugin YourPlugin
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Edit `manifest.json` — set description, capabilities, requirements
|
||||||
|
4. Implement your plugin in `lib/your_plugin/plugin.ex`
|
||||||
|
5. Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix deps.get
|
||||||
|
mix test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For local development alongside a Tribes checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Symlink into the host plugins directory
|
||||||
|
cd /path/to/tribes
|
||||||
|
ln -s /path/to/your-plugin plugins/your_plugin
|
||||||
|
|
||||||
|
# Start Tribes dev server — your plugin loads automatically
|
||||||
|
iex --sname dev -S mix phx.server
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit your plugin source. Phoenix code reloader picks up changes.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
your_plugin/
|
||||||
|
├── manifest.json # Plugin metadata (Nix build + runtime)
|
||||||
|
├── mix.exs # Dependencies
|
||||||
|
├── lib/
|
||||||
|
│ ├── your_plugin/
|
||||||
|
│ │ ├── plugin.ex # Tribes.Plugin entry point
|
||||||
|
│ │ └── application.ex # OTP supervision tree (optional)
|
||||||
|
│ └── your_plugin_web/
|
||||||
|
│ └── live/ # LiveView pages
|
||||||
|
├── assets/ # JS/CSS (one bundle per plugin)
|
||||||
|
├── priv/
|
||||||
|
│ ├── static/ # Built assets for release
|
||||||
|
│ └── repo/migrations/ # Ecto migrations
|
||||||
|
└── test/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest
|
||||||
|
|
||||||
|
`manifest.json` declares your plugin's identity and capabilities:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "your_plugin",
|
||||||
|
"provides": ["some_capability@1"],
|
||||||
|
"requires": ["ecto@1"],
|
||||||
|
"enhances_with": ["inference@1"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **provides** — capabilities this plugin makes available
|
||||||
|
- **requires** — hard dependencies (build fails without them)
|
||||||
|
- **enhances_with** — optional dependencies (plugin degrades gracefully)
|
||||||
|
|
||||||
|
See the [Plugin System docs](https://github.com/your-org/tribes/blob/master/docs/PLUGINS.md) for the full specification.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Three test levels:
|
||||||
|
|
||||||
|
- **Unit tests** (`test/your_plugin/`) — plugin logic in isolation
|
||||||
|
- **Manifest tests** (`test/your_plugin/manifest_test.exs`) — manifest schema validation
|
||||||
|
- **Contract tests** (`test/contract_test.exs`) — runtime spec matches manifest
|
||||||
|
|
||||||
|
Run all: `mix test`
|
||||||
|
|
||||||
|
## Building for Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix compile
|
||||||
|
|
||||||
|
mkdir -p dist/your_plugin
|
||||||
|
cp -r _build/prod/lib/your_plugin/ebin dist/your_plugin/
|
||||||
|
cp -r priv dist/your_plugin/
|
||||||
|
cp manifest.json dist/your_plugin/
|
||||||
|
```
|
||||||
|
|
||||||
|
For Nix-based deployment, add your plugin to the host's `plugins.json`.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
TODO: Choose a licence.
|
||||||
13
assets/css/my_plugin.css
Normal file
13
assets/css/my_plugin.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Plugin CSS entry point.
|
||||||
|
*
|
||||||
|
* Served at /plugins-assets/my_plugin/my_plugin.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
|
||||||
|
* with host or other plugin styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.my-plugin {
|
||||||
|
/* Plugin-scoped styles go here */
|
||||||
|
}
|
||||||
15
assets/js/my_plugin.js
Normal file
15
assets/js/my_plugin.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Plugin JavaScript entry point.
|
||||||
|
//
|
||||||
|
// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js
|
||||||
|
// and included in the page layout if declared in manifest.json assets.global_js.
|
||||||
|
//
|
||||||
|
// To register LiveView hooks:
|
||||||
|
//
|
||||||
|
// window.TribesPluginHooks = window.TribesPluginHooks || {};
|
||||||
|
// window.TribesPluginHooks["MyPluginHook"] = {
|
||||||
|
// mounted() {
|
||||||
|
// console.log("MyPlugin hook mounted");
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
console.log("my_plugin loaded");
|
||||||
21
lib/my_plugin/application.ex
Normal file
21
lib/my_plugin/application.ex
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
defmodule MyPlugin.Application do
|
||||||
|
@moduledoc """
|
||||||
|
OTP Application for this plugin.
|
||||||
|
|
||||||
|
Uncomment the `mod:` entry in mix.exs to activate this supervision tree.
|
||||||
|
Add plugin-specific GenServers, workers, or supervisors as children here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
# Add your supervised processes here, e.g.:
|
||||||
|
# {MyPlugin.Worker, []}
|
||||||
|
]
|
||||||
|
|
||||||
|
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
81
lib/my_plugin/plugin.ex
Normal file
81
lib/my_plugin/plugin.ex
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
defmodule MyPlugin.Plugin do
|
||||||
|
@moduledoc """
|
||||||
|
Tribes plugin entry point.
|
||||||
|
|
||||||
|
Implements the `Tribes.Plugin` behaviour. This module is referenced by
|
||||||
|
`entry_module` in manifest.json and is called by the host's PluginManager
|
||||||
|
during startup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Once Tribes.Plugin.Base is available, replace the manual implementation
|
||||||
|
# with:
|
||||||
|
#
|
||||||
|
# use Tribes.Plugin.Base, otp_app: :my_plugin
|
||||||
|
#
|
||||||
|
# and override only register/1.
|
||||||
|
|
||||||
|
# @behaviour Tribes.Plugin
|
||||||
|
|
||||||
|
def register(_context) do
|
||||||
|
manifest = read_manifest()
|
||||||
|
|
||||||
|
%{
|
||||||
|
name: manifest["name"],
|
||||||
|
version: manifest["version"],
|
||||||
|
provides: manifest["provides"] || [],
|
||||||
|
requires: manifest["requires"] || [],
|
||||||
|
enhances_with: manifest["enhances_with"] || [],
|
||||||
|
nav_items: [
|
||||||
|
%{
|
||||||
|
label: "My Plugin",
|
||||||
|
path: "/plugins/my_plugin",
|
||||||
|
icon: nil,
|
||||||
|
requires: [],
|
||||||
|
order: 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pages: [
|
||||||
|
%{
|
||||||
|
path: "/plugins/my_plugin",
|
||||||
|
live_view: MyPluginWeb.HomeLive,
|
||||||
|
layout: nil
|
||||||
|
}
|
||||||
|
],
|
||||||
|
api_routes: [],
|
||||||
|
plugs: [],
|
||||||
|
children: [],
|
||||||
|
global_js: get_in(manifest, ["assets", "global_js"]) || [],
|
||||||
|
global_css: get_in(manifest, ["assets", "global_css"]) || [],
|
||||||
|
migrations_path: migrations_path(manifest),
|
||||||
|
hooks: %{}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_manifest do
|
||||||
|
manifest_path()
|
||||||
|
|> File.read!()
|
||||||
|
|> Jason.decode!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp manifest_path do
|
||||||
|
# In a release, manifest.json sits alongside ebin/ in the plugin directory.
|
||||||
|
# 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 ->
|
||||||
|
priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp migrations_path(manifest) do
|
||||||
|
if manifest["migrations"] do
|
||||||
|
case :code.priv_dir(:my_plugin) do
|
||||||
|
{:error, :bad_name} -> nil
|
||||||
|
priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
30
lib/my_plugin_web/live/home_live.ex
Normal file
30
lib/my_plugin_web/live/home_live.ex
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
defmodule MyPluginWeb.HomeLive do
|
||||||
|
@moduledoc """
|
||||||
|
Example LiveView page for the plugin.
|
||||||
|
|
||||||
|
This page is registered in the plugin spec and mounted by the host
|
||||||
|
at /plugins/my_plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# In dev mode (plugin loaded as path dep), you can use host macros:
|
||||||
|
# use TribesWeb, :live_view
|
||||||
|
#
|
||||||
|
# For release builds (standalone OTP app), use Phoenix.LiveView directly:
|
||||||
|
use Phoenix.LiveView
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, assign(socket, :page_title, "My Plugin")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-2xl font-bold">My Plugin</h1>
|
||||||
|
<p class="mt-2 text-base-content/70">
|
||||||
|
This is a Tribes plugin. Edit this page in
|
||||||
|
<code>lib/my_plugin_web/live/home_live.ex</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
16
manifest.json
Normal file
16
manifest.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "my_plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TODO: Describe what this plugin does",
|
||||||
|
"entry_module": "MyPlugin.Plugin",
|
||||||
|
"host_api": "1",
|
||||||
|
"provides": [],
|
||||||
|
"requires": ["ecto@1"],
|
||||||
|
"enhances_with": [],
|
||||||
|
"assets": {
|
||||||
|
"global_js": ["my_plugin.js"],
|
||||||
|
"global_css": ["my_plugin.css"]
|
||||||
|
},
|
||||||
|
"migrations": false,
|
||||||
|
"children": false
|
||||||
|
}
|
||||||
46
mix.exs
Normal file
46
mix.exs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
defmodule MyPlugin.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :my_plugin,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.18",
|
||||||
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
deps: deps(),
|
||||||
|
aliases: aliases()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
extra_applications: [:logger]
|
||||||
|
# Uncomment if your plugin needs its own supervision tree:
|
||||||
|
# mod: {MyPlugin.Application, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||||
|
defp elixirc_paths(_), do: ["lib"]
|
||||||
|
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
# Host dependency — provides Tribes.Plugin behaviour, types, and test helpers.
|
||||||
|
#
|
||||||
|
# For local development alongside a tribes checkout:
|
||||||
|
{:tribes, path: "../tribes", only: [:dev, :test]},
|
||||||
|
#
|
||||||
|
# For CI or standalone development (when not co-located with tribes):
|
||||||
|
# {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]},
|
||||||
|
|
||||||
|
{:jason, "~> 1.2"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp aliases do
|
||||||
|
[
|
||||||
|
test: ["test"]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
0
priv/repo/migrations/.gitkeep
Normal file
0
priv/repo/migrations/.gitkeep
Normal file
0
priv/static/.gitkeep
Normal file
0
priv/static/.gitkeep
Normal file
68
scripts/rename.sh
Executable file
68
scripts/rename.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Renames the template plugin to your chosen name.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/rename.sh <snake_case_name> <ModuleName>
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# ./scripts/rename.sh billing_reports BillingReports
|
||||||
|
|
||||||
|
if [ "$#" -ne 2 ]; then
|
||||||
|
echo "Usage: $0 <snake_case_name> <ModuleName>"
|
||||||
|
echo "Example: $0 billing_reports BillingReports"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SNAKE="$1"
|
||||||
|
MODULE="$2"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
if ! echo "$SNAKE" | grep -qE '^[a-z][a-z0-9_]*$'; then
|
||||||
|
echo "Error: snake_case_name must match [a-z][a-z0-9_]*"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! echo "$MODULE" | grep -qE '^[A-Z][a-zA-Z0-9]*$'; then
|
||||||
|
echo "Error: ModuleName must start with uppercase and contain only alphanumeric characters"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Renaming my_plugin -> $SNAKE, MyPlugin -> $MODULE"
|
||||||
|
|
||||||
|
# Rename directories
|
||||||
|
if [ -d "lib/my_plugin" ]; then
|
||||||
|
mv "lib/my_plugin" "lib/$SNAKE"
|
||||||
|
fi
|
||||||
|
if [ -d "lib/my_plugin_web" ]; then
|
||||||
|
mv "lib/my_plugin_web" "lib/${SNAKE}_web"
|
||||||
|
fi
|
||||||
|
if [ -d "test/my_plugin" ]; then
|
||||||
|
mv "test/my_plugin" "test/$SNAKE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Replace in all text files
|
||||||
|
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 -i '' \
|
||||||
|
-e "s/my_plugin/$SNAKE/g" \
|
||||||
|
-e "s/MyPlugin/$MODULE/g" \
|
||||||
|
-e "s/my-plugin/$SNAKE/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Rename asset files
|
||||||
|
for ext in js css; do
|
||||||
|
if [ -f "assets/$ext/my_plugin.$ext" ]; then
|
||||||
|
mv "assets/$ext/my_plugin.$ext" "assets/$ext/$SNAKE.$ext"
|
||||||
|
fi
|
||||||
|
if [ -f "priv/static/my_plugin.$ext" ]; then
|
||||||
|
mv "priv/static/my_plugin.$ext" "priv/static/$SNAKE.$ext"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done. Review the changes, then:"
|
||||||
|
echo " 1. Edit manifest.json — set description, capabilities"
|
||||||
|
echo " 2. Run: mix deps.get && mix test"
|
||||||
86
test/contract_test.exs
Normal file
86
test/contract_test.exs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
defmodule MyPlugin.ContractTest do
|
||||||
|
@moduledoc """
|
||||||
|
Contract compliance tests.
|
||||||
|
|
||||||
|
These verify that the plugin conforms to the Tribes plugin contract.
|
||||||
|
When Tribes.Plugin.ContractTest is available (host dep loaded),
|
||||||
|
replace this file with:
|
||||||
|
|
||||||
|
defmodule MyPlugin.ContractTest do
|
||||||
|
use Tribes.Plugin.ContractTest,
|
||||||
|
plugin: MyPlugin.Plugin,
|
||||||
|
otp_app: :my_plugin
|
||||||
|
end
|
||||||
|
|
||||||
|
Until then, this file provides a standalone equivalent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
@plugin MyPlugin.Plugin
|
||||||
|
@manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand()
|
||||||
|
|
||||||
|
setup_all do
|
||||||
|
manifest = @manifest_path |> File.read!() |> Jason.decode!()
|
||||||
|
spec = @plugin.register(%{pubsub: nil, repo: nil})
|
||||||
|
%{manifest: manifest, spec: spec}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runtime provides matches manifest provides", %{manifest: manifest, spec: spec} do
|
||||||
|
normalise = fn cap ->
|
||||||
|
if String.contains?(cap, "@"), do: cap, else: "#{cap}@1"
|
||||||
|
end
|
||||||
|
|
||||||
|
manifest_caps = manifest["provides"] |> Enum.map(normalise) |> Enum.sort()
|
||||||
|
runtime_caps = spec.provides |> Enum.map(normalise) |> Enum.sort()
|
||||||
|
|
||||||
|
assert manifest_caps == runtime_caps,
|
||||||
|
"manifest provides #{inspect(manifest_caps)} != runtime provides #{inspect(runtime_caps)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runtime requires matches manifest requires", %{manifest: manifest, spec: spec} do
|
||||||
|
normalise = fn cap ->
|
||||||
|
if String.contains?(cap, "@"), do: cap, else: "#{cap}@1"
|
||||||
|
end
|
||||||
|
|
||||||
|
manifest_caps = manifest["requires"] |> Enum.map(normalise) |> Enum.sort()
|
||||||
|
runtime_caps = spec.requires |> Enum.map(normalise) |> Enum.sort()
|
||||||
|
|
||||||
|
assert manifest_caps == runtime_caps,
|
||||||
|
"manifest requires #{inspect(manifest_caps)} != runtime requires #{inspect(runtime_caps)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "spec has all required keys", %{spec: spec} do
|
||||||
|
required_keys = [
|
||||||
|
:name,
|
||||||
|
:version,
|
||||||
|
:provides,
|
||||||
|
:requires,
|
||||||
|
:nav_items,
|
||||||
|
:pages,
|
||||||
|
:children,
|
||||||
|
:global_js,
|
||||||
|
:global_css,
|
||||||
|
:migrations_path,
|
||||||
|
:hooks
|
||||||
|
]
|
||||||
|
|
||||||
|
for key <- required_keys do
|
||||||
|
assert Map.has_key?(spec, key), "spec must contain #{inspect(key)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "name in spec matches manifest", %{manifest: manifest, spec: spec} do
|
||||||
|
assert spec.name == manifest["name"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "migrations_path exists if manifest declares migrations", %{manifest: manifest, spec: spec} do
|
||||||
|
if manifest["migrations"] do
|
||||||
|
assert is_binary(spec.migrations_path),
|
||||||
|
"migrations_path must be set when manifest declares migrations: true"
|
||||||
|
|
||||||
|
assert File.dir?(spec.migrations_path),
|
||||||
|
"migrations_path #{inspect(spec.migrations_path)} must be an existing directory"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
59
test/my_plugin/manifest_test.exs
Normal file
59
test/my_plugin/manifest_test.exs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
defmodule MyPlugin.ManifestTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
@manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand()
|
||||||
|
|
||||||
|
setup_all do
|
||||||
|
content = File.read!(@manifest_path)
|
||||||
|
manifest = Jason.decode!(content)
|
||||||
|
%{manifest: manifest}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "manifest.json" do
|
||||||
|
test "is valid JSON", %{manifest: manifest} do
|
||||||
|
assert is_map(manifest)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has required fields", %{manifest: manifest} do
|
||||||
|
required = ["name", "version", "entry_module", "host_api", "provides", "requires"]
|
||||||
|
|
||||||
|
for field <- required do
|
||||||
|
assert Map.has_key?(manifest, field),
|
||||||
|
"manifest.json must contain #{inspect(field)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "name matches OTP app name", %{manifest: manifest} do
|
||||||
|
assert manifest["name"] == "my_plugin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "entry_module is a valid Elixir module name", %{manifest: manifest} do
|
||||||
|
module_name = manifest["entry_module"]
|
||||||
|
assert is_binary(module_name)
|
||||||
|
assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "provides contains valid capability identifiers", %{manifest: manifest} do
|
||||||
|
for cap <- manifest["provides"] do
|
||||||
|
assert Regex.match?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap),
|
||||||
|
"invalid capability identifier: #{inspect(cap)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires contains valid capability identifiers", %{manifest: manifest} do
|
||||||
|
for cap <- manifest["requires"] do
|
||||||
|
assert Regex.match?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap),
|
||||||
|
"invalid capability identifier: #{inspect(cap)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "host_api is a string version", %{manifest: manifest} do
|
||||||
|
assert is_binary(manifest["host_api"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "entry_module matches actual plugin module", %{manifest: manifest} do
|
||||||
|
module = String.to_atom("Elixir.#{manifest["entry_module"]}")
|
||||||
|
assert Code.ensure_loaded?(module), "entry_module #{manifest["entry_module"]} must be loadable"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
63
test/my_plugin/plugin_test.exs
Normal file
63
test/my_plugin/plugin_test.exs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
defmodule MyPlugin.PluginTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
describe "register/1" do
|
||||||
|
setup do
|
||||||
|
context = %{pubsub: nil, repo: nil}
|
||||||
|
%{spec: MyPlugin.Plugin.register(context)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns plugin name and version", %{spec: spec} do
|
||||||
|
assert spec.name == "my_plugin"
|
||||||
|
assert is_binary(spec.version)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "provides is a list of capability strings", %{spec: spec} do
|
||||||
|
assert is_list(spec.provides)
|
||||||
|
|
||||||
|
for cap <- spec.provides do
|
||||||
|
assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires is a list of capability strings", %{spec: spec} do
|
||||||
|
assert is_list(spec.requires)
|
||||||
|
|
||||||
|
for cap <- spec.requires do
|
||||||
|
assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nav items have required fields", %{spec: spec} do
|
||||||
|
assert is_list(spec.nav_items)
|
||||||
|
|
||||||
|
for item <- spec.nav_items do
|
||||||
|
assert is_binary(item.label), "nav item must have a label"
|
||||||
|
assert is_binary(item.path), "nav item must have a path"
|
||||||
|
assert is_integer(item.order), "nav item must have an integer order"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pages reference existing modules", %{spec: spec} do
|
||||||
|
assert is_list(spec.pages)
|
||||||
|
|
||||||
|
for page <- spec.pages do
|
||||||
|
assert is_binary(page.path), "page must have a path"
|
||||||
|
assert is_atom(page.live_view), "page must have a live_view module"
|
||||||
|
assert Code.ensure_loaded?(page.live_view), "module #{page.live_view} must be loadable"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "children are valid child specs", %{spec: spec} do
|
||||||
|
assert is_list(spec.children)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "asset lists are string lists", %{spec: spec} do
|
||||||
|
assert is_list(spec.global_js)
|
||||||
|
assert is_list(spec.global_css)
|
||||||
|
|
||||||
|
for js <- spec.global_js, do: assert(is_binary(js))
|
||||||
|
for css <- spec.global_css, do: assert(is_binary(css))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
1
test/test_helper.exs
Normal file
1
test/test_helper.exs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ExUnit.start()
|
||||||
Reference in New Issue
Block a user