Compare commits

...

4 commits

Author SHA1 Message Date
James Harton 4e94d47bd4 chore: release version v0.4.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-20 02:09:14 +00:00
James Harton a1b229d1a9
fix: bug in constant mapper.
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2023-09-20 12:54:40 +12:00
James Harton cba62306e3
docs: Improve docs and add spark DSL cheat sheets. 2023-09-20 12:54:40 +12:00
James Harton bb034f8edb
chore(CI): don't release on PR builds (WTF) 2023-09-20 10:49:15 +12:00
14 changed files with 511 additions and 129 deletions

View file

@ -198,6 +198,19 @@ steps:
commands:
- asdf mix spark.formatter --check
- name: mix spark.cheat_sheets
image: code.harton.nz/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
MIX_HOME: /drone/src/.mix
REBAR_BASE_DIR: /drone/src/.rebar3
ASDF_DATA_DIR: /drone/src/.asdf
depends_on:
- mix compile
commands:
- asdf mix spark.cheat_sheets --check
- name: mix deps.unlock
image: code.harton.nz/james/asdf_container:latest
environment:
@ -241,8 +254,11 @@ steps:
- name: mix git_ops.release
image: code.harton.nz/james/asdf_container:latest
when:
branch:
branch:
- main
event:
exclude:
- pull_request
depends_on:
- mix test
- mix credo

View file

@ -5,6 +5,15 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
<!-- changelog -->
## [v0.4.1](https://code.harton.nz/james/smokestack/compare/v0.4.0...v0.4.1) (2023-09-20)
### Bug Fixes:
* bug in constant mapper.
## [v0.4.0](https://code.harton.nz/james/smokestack/compare/v0.3.1...v0.4.0) (2023-09-19)

View file

@ -0,0 +1,153 @@
# DSL: Smokestack.Dsl
The DSL definition for the Smokestack DSL.
<!--- ash-hq-hide-start --> <!--- -->
## DSL Documentation
### Index
* smokestack
* factory
* attribute
### Docs
## smokestack
* [factory](#module-factory)
* attribute
---
* `:api` (`t:atom/0`) - The default Ash API to use when evaluating loads
### factory
Define factories for a resource
* attribute
* `:api` (`t:atom/0`) - The Ash API to use when evaluating loads
* `:resource` (`t:atom/0`) - Required. An Ash Resource
* `:variant` (`t:atom/0`) - The name of a factory variant The default value is `:default`.
##### attribute
* `:name` (`t:atom/0`) - Required. The name of the target attribute
* `:generator` - Required. A function which can generate an appropriate value for the attribute.œ
<!--- ash-hq-hide-stop --> <!--- -->
## smokestack
### Nested DSLs
* [factory](#smokestack-factory)
* attribute
### Options
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `api` | `module` | | The default Ash API to use when evaluating loads |
## smokestack.factory
```elixir
factory resource, variant \ :default
```
Define factories for a resource
### Nested DSLs
* [attribute](#smokestack-factory-attribute)
### Arguments
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `resource`* | `module` | | An Ash Resource |
| `variant` | `atom` | `:default` | The name of a factory variant |
### Options
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `api` | `module` | | The Ash API to use when evaluating loads |
## smokestack.factory.attribute
```elixir
attribute name, generator
```
### Arguments
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `name`* | `atom` | | The name of the target attribute |
| `generator`* | `(-> any) \| mfa \| (any -> any) \| mfa \| (any, any -> any) \| mfa` | | A function which can generate an appropriate value for the attribute.œ |
### Introspection
Target: `Smokestack.Dsl.Attribute`
### Introspection
Target: `Smokestack.Dsl.Factory`

View file

@ -2,8 +2,8 @@ defmodule Smokestack do
alias Spark.{Dsl, Dsl.Extension}
@moduledoc """
Smokestack provides a way to define test factories for your
[Ash Resources](https://ash-hq.org/docs/module/ash/latest/ash-resource)
Smokestack provides a way to define test factories for your
[Ash Resources](https://ash-hq.org/docs/module/ash/latest/ash-resource)
using a convenient DSL:
```
@ -28,7 +28,7 @@ defmodule Smokestack do
## Templates
Each attribute uses a template to generate a value when building a factory.
Each attribute uses a template to generate a value when building a factory.
Templates can be anything that implements the `Smokestack.Template` protocol.
This protocol is automatically implemented for functions with arities zero
through two - meaning you can just drop in your own functions - or use one of
@ -37,7 +37,7 @@ defmodule Smokestack do
## Variants
Sometimes you need to make slightly different factories to build a resource
in a specific state for your test scenario.
in a specific state for your test scenario.
Here's an example defining an alternate `:trek` variant for the character
factory defined above:
@ -57,7 +57,7 @@ defmodule Smokestack do
### Options
- `load`: an atom, list of atoms or keyword list of the same listing
- `load`: an atom, list of atoms or keyword list of the same listing
relationships, calculations and aggregates that should be loaded
after the record is created.
- `count`: rather than inserting just a single record, you can specify a
@ -129,17 +129,21 @@ defmodule Smokestack do
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.ParamBuilder.build/2` for more information.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@callback params(Resource.t(), [param_option]) ::
{:ok, ParamBuilder.result()} | {:error, any}
@doc """
Raising version of `params/2`.
Raising version of `c:params/2`.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.ParamBuilder.build/3` for more information.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@callback params!(Resource.t(), [param_option]) :: ParamBuilder.result() | no_return
@ -148,17 +152,21 @@ defmodule Smokestack do
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.RecordBuilder.build/3` for more information.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@callback insert(Resource.t(), [insert_option]) ::
{:ok, RecordBuilder.result()} | {:error, any}
@doc """
Raising version of `insert/4`.
Raising version of `c:insert/2`.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.RecordBuilder.build/3` for more information.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@callback insert!(Resource.t(), [insert_option]) :: RecordBuilder.result() | no_return
@ -168,11 +176,20 @@ defmodule Smokestack do
quote do
@behaviour Smokestack
@moduledoc @moduledoc ||
"""
A Smokestack factory.
See `Smokestack` for more information.
"""
@doc """
Runs a factory and uses it to build a parameters suitable for simulating a
request.
See `c:Smokestack.build/2` for more information.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@spec params(Resource.t(), [Smokestack.param_option()]) ::
{:ok, ParamBuilder.result()} | {:error, any}
@ -187,7 +204,9 @@ defmodule Smokestack do
@doc """
Raising version of `params/2`.
See `c:Smokestack.build/3` for more information.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@spec params!(Resource.t(), [Smokestack.param_option()]) ::
ParamBuilder.result() | no_return
@ -201,7 +220,9 @@ defmodule Smokestack do
@doc """
Execute the matching factory and return an inserted Ash Resource record.
See `c:Smokestack.insert/2` for more information.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@spec insert(Resource.t(), [Smokestack.insert_option()]) ::
{:ok, RecordBuilder.result()} | {:error, any}
@ -216,7 +237,9 @@ defmodule Smokestack do
@doc """
Raising version of `insert/2`.
See `c:Smokestack.insert/2` for more information.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@spec insert!(Resource.t(), [Smokestack.insert_option()]) ::
RecordBuilder.result() | no_return

View file

@ -18,7 +18,7 @@ defmodule Smokestack.Builder do
@doc """
Provide a schema for validating options.
"""
@callback option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, any}
@callback option_schema(nil | Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, any}
@doc """
Given a builder and a factory, validate it's options and call the builder.
@ -30,4 +30,13 @@ defmodule Smokestack.Builder do
builder.build(factory, options)
end
end
@doc """
Generate documentation for the available options.
"""
@spec docs(t, nil | Factory.t()) :: String.t()
def docs(builder, factory) do
{:ok, schema} = builder.option_schema(factory)
OptionsHelpers.docs(schema)
end
end

View file

@ -42,13 +42,17 @@ defmodule Smokestack.FactoryBuilder do
@doc false
@impl true
@spec option_schema(Factory.t()) ::
@spec option_schema(nil | Factory.t()) ::
{:ok, OptionsHelpers.schema()} | {:error, error}
def option_schema(factory) do
attr_keys =
factory.resource
|> Resource.Info.attributes()
|> Enum.map(&{&1.name, [type: :any, required: false]})
if factory do
factory.resource
|> Resource.Info.attributes()
|> Enum.map(&{&1.name, [type: :any, required: false]})
else
[{:*, [type: :any, required: false]}]
end
{:ok,
[
@ -56,7 +60,20 @@ defmodule Smokestack.FactoryBuilder do
type: :map,
required: false,
default: %{},
keys: attr_keys
keys: attr_keys,
doc: """
Attribute overrides.
You can directly specify any overrides you would like set on the
resulting record without running their normal generator.
For example:
```elixir
post = params!(Post, attrs: %{title: "What's wrong with Huntly?"})
assert post.title == "What's wrong with Huntly?"
```
"""
]
]}
end

View file

@ -30,10 +30,32 @@ defmodule Smokestack.ManyBuilder do
@doc false
@impl true
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
@spec option_schema(nil | Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
def option_schema(factory) do
with {:ok, schema} <- RelatedBuilder.option_schema(factory) do
{:ok, Keyword.put(schema, :count, type: :pos_integer, required: true)}
with {:ok, related_schema} <- RelatedBuilder.option_schema(factory) do
schema =
[
count: [
type: :pos_integer,
required: true,
doc: """
Specify the number of instances to build.
Use this option to run the factory a number of times and return the
results as a list.
For example:
```elixir
posts = params!(Post, count: 3)
assert length(posts) == 3
```
"""
]
]
|> OptionsHelpers.merge_schemas(related_schema, "Options for building relationships")
{:ok, schema}
end
end

View file

@ -69,59 +69,117 @@ defmodule Smokestack.ParamBuilder do
@doc false
@impl true
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
@spec option_schema(nil | Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
def option_schema(factory) do
with {:ok, schema0} <- RelatedBuilder.option_schema(factory),
{:ok, schema1} <- ManyBuilder.option_schema(factory) do
schema1 =
Keyword.update!(schema1, :count, fn current ->
with {:ok, related_schema} <- RelatedBuilder.option_schema(factory),
{:ok, many_schema} <- ManyBuilder.option_schema(factory) do
many_schema =
Keyword.update!(many_schema, :count, fn current ->
current
|> Keyword.update!(:type, &{:or, [&1, nil]})
|> Keyword.put(:default, nil)
|> Keyword.put(:required, false)
end)
our_schema = [
encode: [
type: {:or, [nil, :module]},
required: false,
doc: """
Provide an encoder module.
If provided the result will be passed to an `encode/1` function
which should return an ok/error tuple with the encoded result.
This is primarily for use with `Jason` or `Poison`, however you may
wish to define your own encoder for your tests.
For example:
```elixir
post = params!(Post, encoder: Jason)
assert Jason.decode!(post)
```
"""
],
nest: [
type: {:or, [:atom, :string]},
required: false,
doc: """
Nest the result within an arbitrary map key.
Mostly provided as a shorthand for building API requests where the
built instance may need to be nested inside a key such as `data`.
Takes the result and nests it in a map under the provided key.
For example:
```elixir
request = params!(Post, nest: :data)
assert is_binary(request.data.title)
```
If you need a more complicated mangling of the result, I suggest
using the `encode` option.
"""
],
key_case: [
type:
{:or,
[
{:tuple, [{:literal, :path}, :string]},
{:in,
[
:camel,
:constant,
:dot,
:header,
:kebab,
:name,
:pascal,
:path,
:sentence,
:snake,
:title
]}
]},
required: false,
default: :snake,
doc: """
Change the key case.
Sometimes you will need to change the case of the keys before sending
to an API. Behind the scenes we use [recase](https://hexdocs.pm/recase)
to remap the keys as specified.
For example:
iex> params!(Post, key_case: :kebab) |> Map.keys()
[:title, :"sub-title"]
"""
],
key_type: [
type: {:in, [:string, :atom]},
required: false,
default: :atom,
doc: """
Specify string or atom keys.
Allows you to specify the type of the returned keys.
For example:
iex> params!(Post, key_type: :string) |> Map.keys()
["title", "sub_title"]
"""
]
]
schema =
schema0
|> Keyword.merge(schema1)
|> Keyword.merge(
encode: [
type: {:or, [nil, :module]},
required: false
],
nest: [
type: {:or, [:atom, :string]},
required: false
],
key_case: [
type:
{:or,
[
{:tuple, [{:literal, :path}, :string]},
{:in,
[
:camel,
:constant,
:dot,
:header,
:kebab,
:name,
:pascal,
:path,
:sentence,
:snake,
:title
]}
]},
required: false,
default: :snake
],
key_type: [
type: {:in, [:string, :atom]},
required: false,
default: :atom
]
)
our_schema
|> OptionsHelpers.merge_schemas(many_schema, "Options for building multiple instances")
|> OptionsHelpers.merge_schemas(related_schema, "Options for building relationships")
{:ok, schema}
end

View file

@ -31,20 +31,35 @@ defmodule Smokestack.RecordBuilder do
@doc false
@impl true
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
@spec option_schema(nil | Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
def option_schema(factory) do
with {:ok, schema0} <- RelatedBuilder.option_schema(factory),
{:ok, schema1} <- ManyBuilder.option_schema(factory) do
loadable_names =
factory.resource
|> Resource.Info.relationships()
|> Enum.concat(Resource.Info.aggregates(factory.resource))
|> Enum.concat(Resource.Info.calculations(factory.resource))
|> Enum.map(& &1.name)
|> Enum.uniq()
with {:ok, related_schema} <- RelatedBuilder.option_schema(factory),
{:ok, many_schema} <- ManyBuilder.option_schema(factory) do
load_type =
if factory do
loadable_names =
factory.resource
|> Resource.Info.relationships()
|> Enum.concat(Resource.Info.aggregates(factory.resource))
|> Enum.concat(Resource.Info.calculations(factory.resource))
|> Enum.map(& &1.name)
|> Enum.uniq()
schema1 =
Keyword.update!(schema1, :count, fn current ->
{:or,
[
{:wrap_list, {:in, loadable_names}},
{:keyword_list,
Enum.map(
loadable_names,
&{&1, type: {:or, [:atom, :keyword_list]}, required: false}
)}
]}
else
{:or, [{:wrap_list, :atom}, :keyword_list]}
end
many_schema =
Keyword.update!(many_schema, :count, fn current ->
current
|> Keyword.update!(:type, &{:or, [&1, nil]})
|> Keyword.put(:default, nil)
@ -54,22 +69,26 @@ defmodule Smokestack.RecordBuilder do
schema =
[
load: [
type:
{:or,
[
{:wrap_list, {:in, loadable_names}},
{:keyword_list,
Enum.map(
loadable_names,
&{&1, type: {:or, [:atom, :keyword_list]}, required: false}
)}
]},
type: load_type,
required: false,
default: []
default: [],
doc: """
An optional Ash load statement.
You can request any calculations, aggregates or relationships you
would like loaded on the returned record.
For example:
```elixir
insert!(Post, load: [:full_title])
assert is_binary(post.full_title)
```
"""
]
]
|> Keyword.merge(schema0)
|> Keyword.merge(schema1)
|> OptionsHelpers.merge_schemas(many_schema, "Options for building multiple instances")
|> OptionsHelpers.merge_schemas(related_schema, "Options for building relationships")
{:ok, schema}
end

View file

@ -32,32 +32,72 @@ defmodule Smokestack.RelatedBuilder do
@doc false
@impl true
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
@spec option_schema(nil | Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
def option_schema(factory) do
with {:ok, factory_schema} <- FactoryBuilder.option_schema(factory) do
relationship_names =
factory.resource
|> Resource.Info.relationships()
|> Enum.map(& &1.name)
build_type =
if factory do
relationship_names =
factory.resource
|> Resource.Info.relationships()
|> Enum.map(& &1.name)
schema = [
build: [
type:
{:or,
[
{:wrap_list, {:in, relationship_names}},
{:keyword_list,
Enum.map(
relationship_names,
&{&1, type: {:or, [:atom, :keyword_list]}, required: false}
)}
]},
required: false,
default: []
{:or,
[
{:wrap_list, {:in, relationship_names}},
{:keyword_list,
Enum.map(
relationship_names,
&{&1, type: {:or, [:atom, :keyword_list]}, required: false}
)}
]}
else
{:or, [{:wrap_list, :atom}, :keyword_list]}
end
schema =
[
build: [
type: build_type,
required: false,
default: [],
doc: """
A (nested) list of relationships to build.
A (possibly nested) list of Ash resource relationships which is
traversed building any instances as needed.
For example:
```elixir
post = insert!(Post, build: Author)
assert is_struct(post.author, Author)
```
Caveats:
- When building for a variant other than `:default` a matching
variant factory will be looked for and used if present, otherwise
it will build the default variant instead.
- Note that for relationships whose cardinality is "many" we only
build one instance.
If these caveats are an issue, then you can build them yourself and
pass them in using the `attrs` option.
For example:
```elixir
posts = insert!(Post, count: 3)
author = insert(Author, posts: posts)
```
"""
]
]
]
|> OptionsHelpers.merge_schemas(factory_schema, "Options for building instances")
{:ok, Keyword.merge(factory_schema, schema)}
{:ok, schema}
end
end

View file

@ -8,7 +8,7 @@ defmodule Smokestack.Template.Constant do
def init(constant), do: constant
def generate(constant, _, _) when is_function(constant.mapper, 1),
do: constant.mapper(constant.value)
do: constant.mapper.(constant.value)
def generate(constant, _, _),
do: constant.value

32
mix.exs
View file

@ -1,7 +1,7 @@
defmodule Smokestack.MixProject do
use Mix.Project
@version "0.4.0"
@version "0.4.1"
@moduledoc """
Test factories for Ash resources.
@ -23,18 +23,37 @@ defmodule Smokestack.MixProject do
aliases: aliases(),
dialyzer: [plt_add_apps: [:faker]],
docs: [
main: "Smokestack"
main: "Smokestack",
extra_section: "GUIDES",
formatters: ["html"],
filter_modules: ~r/^Elixir.Smokestack/,
source_url_pattern:
"https://code.harton.nz/james/smokestack/src/branch/main/%{path}#L%{line}",
spark: [
extensions: [
%{
module: Smokestack.Dsl,
name: "Smokestack.Dsl",
target: "Smokestack",
type: "Smokestack"
}
]
]
]
]
end
def package do
[
name: :smokestack,
files: ~w[lib .formatter.exs mix.exs README* LICENSE* CHANGELOG* documentation],
maintainers: ["James Harton <james@harton.nz>"],
licenses: ["HL3-FULL"],
links: %{
"Source" => "https://code.harton.nz/james/smokestack"
}
"Source" => "https://code.harton.nz/james/smokestack",
"Github Mirror" => "https://github.com/jimsynz/smokestack"
},
source_url: "https://code.harton.nz/james/smokestack"
]
end
@ -60,13 +79,14 @@ defmodule Smokestack.MixProject do
{:git_ops, "~> 2.6", opts},
{:mix_audit, "~> 2.1", opts},
{:recase, "~> 0.7"},
{:spark, "~> 1.1"}
{:spark, "~> 1.1 and >= 1.1.39"}
]
end
defp aliases do
[
"spark.formatter": "spark.formatter --extensions=Smokestack.Dsl"
"spark.formatter": "spark.formatter --extensions=Smokestack.Dsl",
"spark.cheat_sheets": "spark.cheat_sheets --extensions=Smokestack.Dsl"
]
end

View file

@ -27,8 +27,8 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"},
"sourceror": {:hex, :sourceror, "0.12.3", "a2ad3a1a4554b486d8a113ae7adad5646f938cad99bf8bfcef26dc0c88e8fade", [:mix], [], "hexpm", "4d4e78010ca046524e8194ffc4683422f34a96f6b82901abbb45acc79ace0316"},
"spark": {:hex, :spark, "1.1.22", "68ba00f9acb4c8bc2c93ef82249493687ddf0f0a4f7e79c3c0e22b06719add56", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "b798b95990eed8f2409df47b818b5dbcd00e9b5c30d0355465d0b04bbf9b5c4c"},
"sourceror": {:hex, :sourceror, "0.14.0", "b6b8552d0240400d66b6f107c1bab7ac1726e998efc797f178b7b517e928e314", [:mix], [], "hexpm", "809c71270ad48092d40bbe251a133e49ae229433ce103f762a2373b7a10a8d8b"},
"spark": {:hex, :spark, "1.1.39", "f143b84a5b796bf2d83ec8fb4793ee9e66e67510c40d785f9a67050bb88e7677", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "d71bc26014c7e7abcdcf553f4cf7c5a5ff96f8365b1e20be3768ce503aafb203"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},

View file

@ -1,4 +0,0 @@
defmodule SmokestackTest do
use ExUnit.Case
doctest Smokestack
end