Floki
20 | Github page 21 | philss 22 |├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-tickets.yml │ └── run_tests.yml ├── .gitignore ├── .tool-versions ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── TODO.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── assert_html.ex └── assert_html │ ├── debug.ex │ ├── dsl.ex │ ├── matcher.ex │ ├── parser.ex │ └── selector.ex ├── mix.exs ├── mix.lock └── test ├── assert_html ├── dsl_test.exs ├── matcher_test.exs └── selector_test.exs ├── assert_html_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ~w{config lib test} 7 | }, 8 | strict: true, 9 | color: true, 10 | checks: [ 11 | {Credo.Check.Readability.MaxLineLength, max_length: 160} 12 | ] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | ] 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [ 3 | assert_html: 1, 4 | assert_html: 2, 5 | assert_html: 3, 6 | assert_html: 4, 7 | refute_html: 1, 8 | refute_html: 2, 9 | refute_html: 3, 10 | refute_html: 4 11 | ] 12 | 13 | [ 14 | inputs: [ 15 | "mix.exs", 16 | "{config,lib,test}/**/*.{ex,exs}" 17 | ], 18 | locals_without_parens: locals_without_parens, 19 | line_length: 120, 20 | export: [ 21 | locals_without_parens: locals_without_parens 22 | ] 23 | ] 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | open-pull-requests-limit: 5 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-tickets.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Tickets 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check-dependabot-pull-requests: 10 | runs-on: ubuntu-latest 11 | name: Check for Dependabot Pull Requests 12 | steps: 13 | - name: Step 1 14 | id: step_1 15 | uses: ARPC/dependabot-tickets@v0.2.2 16 | with: 17 | fogbugz_api_url: ${{ secrets.FOGBUGZ_API_URL}} 18 | fogbugz_token: ${{ secrets.FOGBUGZ_API_TOKEN }} 19 | fogbugz_project: ${{ secrets.FOGBUGZ_PROJECT }} 20 | fogbugz_category: ${{ secrets.FOGBUGZ_CATEGORY}} 21 | planview_api_url: ${{ secrets.PLANVIEW_API_URL }} 22 | planview_auth: ${{ secrets.LEANKIT_AUTH }} 23 | planview_board_id: ${{ secrets.PLANVIEW_BOARD_ID }} 24 | planview_lane_id: ${{ secrets.PLANVIEW_LANE_ID }} 25 | planview_type_id: ${{ secrets.PLANVIEW_TYPE_ID }} 26 | users: "dependabot[bot]" 27 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | container: 13 | image: elixir:1.17.3 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Install dependencies 20 | run: | 21 | mix local.rebar --force 22 | mix local.hex --force 23 | mix deps.get 24 | 25 | - name: Run Tests 26 | env: 27 | MIX_ENV: test 28 | run: | 29 | mix test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | assert_html-*.tar 24 | 25 | 26 | .elixir_ls 27 | .DS_Store 28 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.4-otp-27 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | sudo: false 3 | elixir: 4 | - 1.5 5 | - 1.6 6 | - 1.7 7 | - 1.8 8 | otp_release: 9 | - 19.3 10 | - 20.3 11 | - 21.0 12 | 13 | matrix: 14 | exclude: 15 | - elixir: 1.5 16 | otp_release: 21.0 17 | - elixir: 1.7 18 | otp_release: 19.3 19 | - elixir: 1.7 20 | otp_release: 20.3 21 | - elixir: 1.8 22 | otp_release: 19.3 23 | - elixir: 1.8 24 | otp_release: 20.3 25 | 26 | script: mix coveralls.post -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | 6 | ## v0.0.4 7 | 8 | ## Fixed 9 | - refute attributes 10 | 11 | ## v0.0.3 12 | 13 | ### Fixed 14 | - Checking attributes with non sting values 15 | - Check no existing attributes `attribute_name: nil` 16 | 17 | ### Added 18 | - add `assert_html match: "value"` checker 19 | - Add `assert_html_contains(html, value)` and `refute_html_contains(html, value)` checkers 20 | - Add `assert_html` macro for simplify DSL 21 | ``` 22 | use AssertHTML 23 | 24 | test "shows new page form", %{conn: conn} do 25 | conn_resp = get(conn, Routes.page_path(conn, :new)) 26 | assert response = html_response(conn_resp, 200) 27 | 28 | assert_html(response) do 29 | assert_html("title", "New page") 30 | assert_html("p.description", ~r{You can check text by regular expression}) 31 | refute_html(".check .element .if_doesnt_exist") 32 | assert_html("form.new_page", action: Routes.page_path(conn, :create), method: "post") do 33 | assert_html(".control_group") do 34 | assert_html("label", class: "form-label", text: "Page name") 35 | assert_html("input", type: "text", class: "form-control", value: "", name: "page[name]") 36 | end 37 | assert_html("button", class: "form-button", text: "Submit") 38 | end 39 | end 40 | end 41 | end 42 | ``` 43 | ### Deleted 44 | - Delete `assert_html_contains(html, "text")` -> use `assert_html(html, ~r"text")` instead 45 | - Delete `refute_html_contains(html, "text")` -> use `refute_html(html, ~r"text")` instead 46 | - Delete `refute_html_selector(html, selector)` (use `refute_html(html, selector)` instead) 47 | 48 | ## v0.0.1 49 | 50 | ### Added 51 | - Allow use Regexp for checking attribute value 52 | - Add `assert_attributes(html, selector, [id: "name"], fn(sub_html)-> end)` callback with selected html 53 | - Add `assert_attributes(html, selector, id: "name")` checker 54 | - Add `assert_html_selector(html, css_selector)` and `refute_html_selector((html, css_selector, value)` checkers 55 | - Add `assert_html_text(html, value)` and `assert_html_text(html, css_selector, value)` checkers 56 | - Add `refute_html_text(html, value)` and `refute_html_text((html, css_selector, value)` checkers 57 | - Add `html_selector(html, css_selector)` method 58 | - Add `html_attribute(html, css_selector)` and `html_attribute(html, css_selector, name)` methods 59 | - Add `html_text(html, css_selector)` method 60 | - Basic ExDoc configuration 61 | - Markdown documentation (README, LICENSE, CHANGELOG) -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Anatoliy Kovalchuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AssertHTML: Elixir Library for testing HTML and XML using CSS selectors 2 | 3 | [](https://travis-ci.org/Kr00lIX/assert_html) 4 | [](https://hex.pm/packages/assert_html) 5 | [](https://coveralls.io/github/Kr00lIX/assert_html?branch=master) 6 | 7 | AssertHTML is a powerful Elixir library designed for parsing and extracting data from HTML and XML using CSS. It also provides ExUnit assert helpers for testing rendered HTML using CSS selectors, making it an essential tool for Phoenix Controller and Integration tests. 8 | 9 | ## Features 10 | 11 | - **HTML and XML Parsing**: Easily parse and extract data from HTML and XML documents. 12 | - **CSS Selectors**: Use CSS selectors to find and manipulate elements in your HTML or XML. 13 | - **ExUnit Assert Helpers**: Test your rendered HTML with the help of ExUnit assert helpers. 14 | 15 | ## Getting Started 16 | 17 | Follow these steps to get started with AssertHTML: 18 | 19 | 1. **Install the Library**: Add `assert_html` to your list of dependencies in `mix.exs`: 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:assert_html, "~> 0.1"} 25 | ] 26 | end 27 | ``` 28 | 29 | Then run `mix deps.get` to fetch the dependency. 30 | 31 | 2. **Import formating**: Update your .formatter.exs file with the following import: 32 | 33 | ```elixir 34 | [ 35 | import_deps: [ 36 | :assert_html 37 | ] 38 | ] 39 | ``` 40 | 41 | 3. **Add the Library to your Test**: Add `AssertHTML` to your test file: 42 | 43 | ```elixir 44 | use AssertHTML 45 | ``` 46 | 47 | 48 | ## Usage 49 | 50 | ### Usage in Phoenix Controller and Integration Test 51 | 52 | Assuming the `html_response(conn, 200)` returns: 53 | 54 | ```html 55 | 56 | 57 |
58 |Hello
}) 212 | assert_html(html, match: ~r{Hello
}) 213 | assert_html(html, match: "Hello
") 214 | ``` 215 | 216 | ### Asserts a text element in HTML 217 | 218 | iex> html = ~S{Hello
}) 235 | """ 236 | @spec assert_html(html, Regex.t()) :: html | no_return() 237 | def assert_html(html, %Regex{} = value) do 238 | html(:assert, html, nil, match: value) 239 | end 240 | 241 | @spec assert_html(html, block_fn) :: html | no_return() 242 | def assert_html(html, block_fn) when is_binary(html) and is_function(block_fn) do 243 | html(:assert, html, nil, nil, block_fn) 244 | end 245 | 246 | @spec assert_html(html, css_selector) :: html | no_return() 247 | def assert_html(html, css_selector) when is_binary(html) and is_binary(css_selector) do 248 | html(:assert, html, css_selector) 249 | end 250 | 251 | @spec assert_html(html, attributes) :: html | no_return() 252 | def assert_html(html, attributes) when is_binary(html) and is_list(attributes) do 253 | html(:assert, html, nil, attributes) 254 | end 255 | 256 | @spec assert_html(html, Regex.t(), block_fn) :: html | no_return() 257 | def assert_html(html, %Regex{} = value, block_fn) 258 | when is_binary(html) and is_function(block_fn) do 259 | html(:assert, html, nil, [match: value], block_fn) 260 | end 261 | 262 | @spec assert_html(html, attributes, block_fn) :: html | no_return() 263 | def assert_html(html, attributes, block_fn) 264 | when is_binary(html) and is_list(attributes) and is_function(block_fn) do 265 | html(:assert, html, nil, attributes, block_fn) 266 | end 267 | 268 | @spec assert_html(html, css_selector, block_fn) :: html | no_return() 269 | def assert_html(html, css_selector, block_fn) 270 | when is_binary(html) and is_binary(css_selector) and is_function(block_fn) do 271 | html(:assert, html, css_selector, nil, block_fn) 272 | end 273 | 274 | def assert_html(html, css_selector, attributes, block_fn \\ nil) 275 | 276 | @spec assert_html(html, css_selector, value, block_fn | nil) :: html | no_return() 277 | def assert_html(html, css_selector, %Regex{} = value, block_fn) 278 | when is_binary(html) and is_binary(css_selector) do 279 | html(:assert, html, css_selector, [match: value], block_fn) 280 | end 281 | 282 | def assert_html(html, css_selector, value, block_fn) 283 | when is_binary(html) and is_binary(css_selector) and is_binary(value) do 284 | html(:assert, html, css_selector, [match: value], block_fn) 285 | end 286 | 287 | @spec assert_html(html, css_selector, attributes, block_fn | nil) :: html | no_return() 288 | def assert_html(html, css_selector, attributes, block_fn) do 289 | html(:assert, html, css_selector, attributes, block_fn) 290 | end 291 | 292 | ################################### 293 | ### Refute 294 | 295 | @doc ~S""" 296 | Opposite method for assert_html 297 | 298 | See more (t:refute_html/2) 299 | """ 300 | @spec refute_html(html, Regex.t()) :: html | no_return() 301 | def refute_html(html, %Regex{} = value) do 302 | html(:refute, html, nil, match: value) 303 | end 304 | 305 | @spec refute_html(html, css_selector) :: html | no_return() 306 | def refute_html(html, css_selector) when is_binary(html) and is_binary(css_selector) do 307 | html(:refute, html, css_selector) 308 | end 309 | 310 | @spec refute_html(html, attributes) :: html | no_return() 311 | def refute_html(html, attributes) when is_binary(html) and is_list(attributes) do 312 | html(:refute, html, nil, attributes) 313 | end 314 | 315 | @spec refute_html(html, Regex.t(), block_fn) :: html | no_return() 316 | def refute_html(html, %Regex{} = value, block_fn) 317 | when is_binary(html) and is_function(block_fn) do 318 | html(:refute, html, nil, [match: value], block_fn) 319 | end 320 | 321 | @spec refute_html(html, attributes, block_fn) :: html | no_return() 322 | def refute_html(html, attributes, block_fn) 323 | when is_binary(html) and is_list(attributes) and is_function(block_fn) do 324 | html(:refute, html, nil, attributes, block_fn) 325 | end 326 | 327 | @spec refute_html(html, css_selector, block_fn) :: html | no_return() 328 | def refute_html(html, css_selector, block_fn) 329 | when is_binary(html) and is_binary(css_selector) and is_function(block_fn) do 330 | html(:refute, html, css_selector, nil, block_fn) 331 | end 332 | 333 | def refute_html(html, css_selector, attributes, block_fn \\ nil) 334 | 335 | @spec refute_html(html, css_selector, value, block_fn | nil) :: html | no_return() 336 | def refute_html(html, css_selector, %Regex{} = value, block_fn) do 337 | html(:refute, html, css_selector, [match: value], block_fn) 338 | end 339 | 340 | def refute_html(html, css_selector, value, block_fn) 341 | when is_binary(html) and is_binary(css_selector) and is_binary(value) do 342 | html(:refute, html, css_selector, [match: value], block_fn) 343 | end 344 | 345 | @spec refute_html(html, css_selector, attributes, block_fn | nil) :: html | no_return() 346 | def refute_html(html, css_selector, attributes, block_fn) do 347 | html(:refute, html, css_selector, attributes, block_fn) 348 | end 349 | 350 | defp html(matcher, html_content, css_selector, attributes \\ nil, block_fn \\ nil) 351 | 352 | defp html(matcher, html_content, css_selector, nil = _attributes, block_fn) do 353 | html(matcher, html_content, css_selector, [], block_fn) 354 | end 355 | 356 | defp html(matcher, html_content, css_selector, attributes, block_fn) when is_map(attributes) do 357 | attributes = Enum.into(attributes, []) 358 | html(matcher, html_content, css_selector, attributes, block_fn) 359 | end 360 | 361 | defp html(matcher, html_content, css_selector, attributes, block_fn) 362 | when matcher in [:assert, :refute] and 363 | is_binary(html_content) and 364 | (is_binary(css_selector) or is_nil(css_selector)) and 365 | is_list(attributes) and 366 | (is_function(block_fn) or is_nil(block_fn)) do 367 | Debug.log("call .html with arguments: #{inspect(binding())}") 368 | 369 | params = {collection_params, attributes_params} = Keyword.split(attributes, @collection_checks) 370 | 371 | context = {matcher, html_content} 372 | 373 | # check selector 374 | check_selector(params, context, css_selector) 375 | 376 | # collection checks (:count, :min, :max and :match collection) 377 | check_collection(collection_params, context, css_selector) 378 | 379 | # check element attributes 380 | check_element(attributes_params, context, css_selector) 381 | 382 | # call inside block 383 | if block_fn do 384 | sub_html_content = get_sub_html!(context, css_selector, once: true) 385 | block_fn.(sub_html_content) 386 | end 387 | 388 | html_content 389 | end 390 | 391 | defp check_element(attributes, context, css_selector) 392 | 393 | defp check_element([], _context, _css_selector) do 394 | :skip 395 | end 396 | 397 | defp check_element(attributes, {matcher, html}, css_selector) do 398 | sub_html_content = get_sub_html!({matcher, html}, css_selector, once: true, skip_refute: true) 399 | 400 | Matcher.attributes({matcher, sub_html_content}, attributes) 401 | end 402 | 403 | defp check_collection([], _context, _css_selector) do 404 | :skip 405 | end 406 | 407 | # assert check selection exists 408 | defp check_collection(attributes, {matcher, _html} = context, css_selector) do 409 | # check :match meta-attribute 410 | {contain_value, attributes} = Keyword.pop(attributes, :match) 411 | 412 | if contain_value do 413 | sub_html_content = get_sub_html!(context, css_selector, once: true, skip_refute: true) 414 | Matcher.contain({matcher, sub_html_content}, contain_value) 415 | end 416 | 417 | # check :count meta-attribute 418 | {count_value, attributes} = Keyword.pop(attributes, :count) 419 | count_value && Matcher.count(context, css_selector, count_value) 420 | 421 | # check :min meta-attribute 422 | {min_value, attributes} = Keyword.pop(attributes, :min) 423 | min_value && Matcher.min(context, css_selector, min_value) 424 | 425 | # check :max meta-attribute 426 | {max_value, _attributes} = Keyword.pop(attributes, :max) 427 | max_value && Matcher.max(context, css_selector, max_value) 428 | end 429 | 430 | defp get_sub_html!({_matcher, html_content}, nil, _options) do 431 | html_content 432 | end 433 | 434 | defp get_sub_html!(context, css_selector, options) do 435 | Matcher.selector(context, css_selector, options) 436 | end 437 | 438 | defp check_selector(params, context, css_selector) 439 | 440 | defp check_selector({[], []}, context, css_selector) do 441 | get_sub_html!(context, css_selector, once: true) 442 | end 443 | 444 | defp check_selector({[], _}, context, css_selector) do 445 | get_sub_html!(context, css_selector, once: true, skip_refute: true) 446 | end 447 | 448 | defp check_selector({_, []}, context, css_selector) do 449 | get_sub_html!(context, css_selector, skip_refute: true) 450 | end 451 | 452 | defp check_selector(_params, _mc, _css_selector) do 453 | :ok 454 | end 455 | end 456 | -------------------------------------------------------------------------------- /lib/assert_html/debug.ex: -------------------------------------------------------------------------------- 1 | defmodule AssertHTML.Debug do 2 | @moduledoc false 3 | require Logger 4 | 5 | def log_dsl(entry, level \\ :debug, metadata \\ []) do 6 | if Application.get_env(:assert_html, :log_dsl) do 7 | Logger.log(level, fn -> "\n~~ DSL~~>>>>> \n#{Macro.to_string(entry)} \n<<<<< ~~\n" end, metadata) 8 | end 9 | 10 | entry 11 | end 12 | 13 | def log(entry, level \\ :debug, metadata \\ []) do 14 | if Application.get_env(:assert_html, :log) do 15 | Logger.log(level, fn -> to_iodata(entry) end, metadata) 16 | end 17 | end 18 | 19 | defp to_iodata(entry) when is_binary(entry), do: entry 20 | defp to_iodata(entry), do: inspect(entry) 21 | end 22 | -------------------------------------------------------------------------------- /lib/assert_html/dsl.ex: -------------------------------------------------------------------------------- 1 | defmodule AssertHTML.DSL do 2 | @moduledoc ~S""" 3 | Add aditional syntax to passing current context inside block 4 | 5 | ### Example: pass context 6 | ``` 7 | assert_html html, ".container" do 8 | assert_html "form", action: "/users" do 9 | refute_html ".flash_message" 10 | assert_html ".control_group" do 11 | assert_html "label", class: "title", text: ~r{Full name} 12 | assert_html "input", class: "control", type: "text" 13 | end 14 | assert_html("a", text: "Submit", class: "button") 15 | end 16 | assert_html ".user_list" do 17 | assert_html "li" 18 | end 19 | end 20 | ``` 21 | 22 | ## Example 2: print current context for debug 23 | 24 | ``` 25 | assert_html(html, ".selector") do 26 | IO.inspect(assert_html, label: "current context html") 27 | end 28 | ``` 29 | """ 30 | alias AssertHTML, as: HTML 31 | alias AssertHTML.Debug 32 | 33 | defmacro assert_html(context, selector \\ nil, attributes \\ nil, maybe_do_block \\ nil) do 34 | Debug.log(context: context, selector: selector, attributes: attributes, maybe_do_block: maybe_do_block) 35 | {args, block} = extract_block([context, selector, attributes], maybe_do_block) 36 | 37 | call_html_method(:assert, args, block) 38 | |> Debug.log_dsl() 39 | end 40 | 41 | defmacro refute_html(context, selector \\ nil, attributes \\ nil, maybe_do_block \\ nil) do 42 | Debug.log(context: context, selector: selector, attributes: attributes, maybe_do_block: maybe_do_block) 43 | {args, block} = extract_block([context, selector, attributes], maybe_do_block) 44 | 45 | call_html_method(:refute, args, block) 46 | |> Debug.log_dsl() 47 | end 48 | 49 | defp call_html_method(matcher, args, block \\ nil) 50 | 51 | defp call_html_method(:assert, args, nil) do 52 | quote do 53 | HTML.assert_html(unquote_splicing(args)) 54 | end 55 | end 56 | 57 | defp call_html_method(:refute, args, nil) do 58 | quote do 59 | HTML.refute_html(unquote_splicing(args)) 60 | end 61 | end 62 | 63 | defp call_html_method(matcher, args, block) do 64 | block_arg = 65 | quote do 66 | fn unquote(context_var()) -> 67 | unquote(Macro.prewalk(block, &postwalk/1)) 68 | end 69 | end 70 | 71 | call_html_method(matcher, args ++ [block_arg]) 72 | end 73 | 74 | # found do: block if exists 75 | defp extract_block(args, do: do_block) do 76 | {args, do_block} 77 | end 78 | 79 | defp extract_block(args, _maybe_block) do 80 | args 81 | |> Enum.reverse() 82 | |> Enum.reduce({[], nil}, fn 83 | arg, {args, block} when is_list(arg) -> 84 | {maybe_block, updated_arg} = Keyword.pop(arg, :do) 85 | 86 | { 87 | (updated_arg == [] && args) || [updated_arg | args], 88 | block || maybe_block 89 | } 90 | 91 | nil, {args, block} -> 92 | {args, block} 93 | 94 | arg, {args, block} -> 95 | {[arg | args], block} 96 | end) 97 | end 98 | 99 | # replace assert_html without arguments to context 100 | defp postwalk({:assert_html, env, nil}) do 101 | context_var(env) 102 | end 103 | 104 | defp postwalk({:assert_html, env, arguments}) do 105 | context = context_var(env) 106 | {args, block} = extract_block([context | arguments], nil) 107 | 108 | call_html_method(:assert, args, block) 109 | end 110 | 111 | # replace refute_html without arguments to context 112 | defp postwalk({:refute_html, env, nil}) do 113 | context_var(env) 114 | end 115 | 116 | defp postwalk({:refute_html, env, arguments}) do 117 | context = context_var(env) 118 | {args, block} = extract_block([context | arguments], nil) 119 | 120 | call_html_method(:refute, args, block) 121 | end 122 | 123 | defp postwalk(segment) do 124 | segment 125 | end 126 | 127 | defp context_var(env \\ []) do 128 | {:assert_html_context, env, nil} 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/assert_html/matcher.ex: -------------------------------------------------------------------------------- 1 | defmodule AssertHTML.Matcher do 2 | @moduledoc false 3 | 4 | alias AssertHTML 5 | alias AssertHTML.{Parser, Selector} 6 | 7 | @compile {:inline, raise_match: 3} 8 | 9 | @typep assert_or_refute :: :assert | :refute 10 | 11 | ## ---------------------------------------------------- 12 | ## Collection 13 | 14 | @doc """ 15 | Gets html by selector and raise error if it doesn't exists 16 | 17 | # Options 18 | * `once` - only one element 19 | * `skip_refute` - do not raise error if element exists for refute 20 | """ 21 | @spec selector(AssertHTML.context(), binary(), list()) :: AssertHTML.html() 22 | def selector({matcher, html}, selector, options \\ []) when is_binary(html) and is_binary(selector) do 23 | docs = Parser.find(html, selector) 24 | 25 | # found more than one element 26 | if options[:once] && length(docs) > 1 do 27 | raise_match(matcher, matcher == :assert, fn 28 | :assert -> 29 | "Found more than one element by `#{selector}` selector.\nPlease use `#{selector}:first-child`, `#{selector}:nth-child(n)` for limiting search area.\n\n\t#{html}\n" 30 | 31 | :refute -> 32 | "Selector `#{selector}` succeeded, but should have failed.\n\n\t#{html}\n" 33 | end) 34 | end 35 | 36 | raise_match(matcher, docs == [], fn 37 | :assert -> 38 | "Element `#{selector}` not found.\n\n\t#{html}\n" 39 | 40 | :refute -> 41 | if options[:skip_refute], 42 | do: nil, 43 | else: "Selector `#{selector}` succeeded, but should have failed.\n\n\t#{html}\n" 44 | end) 45 | 46 | Parser.to_html(docs) 47 | end 48 | 49 | @doc """ 50 | Check count of elements on selector 51 | """ 52 | @spec count(AssertHTML.context(), binary(), integer()) :: any() 53 | def count({matcher, html}, selector, check_value) do 54 | count_elements = Parser.count(html, selector) 55 | 56 | raise_match(matcher, count_elements != check_value, fn 57 | :assert -> 58 | [ 59 | message: "Expected #{check_value} element(s). Got #{count_elements} element(s).", 60 | left: count_elements, 61 | right: check_value 62 | ] 63 | 64 | :refute -> 65 | [ 66 | message: "Expected different number of element(s), but received equal", 67 | left: count_elements, 68 | right: check_value 69 | ] 70 | end) 71 | end 72 | 73 | @doc """ 74 | Check count of elements on selector 75 | """ 76 | @spec min(AssertHTML.context(), binary(), integer()) :: any() 77 | def min({matcher, html}, selector, min_value) do 78 | count_elements = Parser.count(html, selector) 79 | 80 | raise_match(matcher, count_elements < min_value, fn 81 | :assert -> 82 | [ 83 | message: "Expected at least #{min_value} element(s). Got #{count_elements} element(s).", 84 | left: count_elements, 85 | right: min_value 86 | ] 87 | 88 | :refute -> 89 | [ 90 | message: "Expected at most #{min_value} element(s). Got #{count_elements} element(s).", 91 | left: count_elements, 92 | right: min_value 93 | ] 94 | end) 95 | end 96 | 97 | @doc """ 98 | Check count of elements on selector 99 | """ 100 | @spec max(AssertHTML.context(), binary(), integer()) :: any() 101 | def max({matcher, html}, selector, max_value) do 102 | count_elements = Parser.count(html, selector) 103 | 104 | raise_match(matcher, count_elements > max_value, fn 105 | :assert -> 106 | [ 107 | message: "Expected at most #{max_value} element(s). Got #{count_elements} element(s).", 108 | left: count_elements, 109 | right: max_value 110 | ] 111 | 112 | :refute -> 113 | [ 114 | message: "Expected at least #{max_value} element(s). Got #{count_elements} element(s).", 115 | left: count_elements, 116 | right: max_value 117 | ] 118 | end) 119 | end 120 | 121 | ## ---------------------------------------------------- 122 | ## Element 123 | 124 | @spec attributes(AssertHTML.context(), AssertHTML.attributes()) :: any() 125 | def attributes({matcher, html}, attributes) when is_list(attributes) do 126 | attributes 127 | |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) 128 | |> Enum.each(fn {attribute, check_value} -> 129 | attr_value = Selector.attribute(html, attribute) 130 | match_attribute(matcher, attribute, check_value, attr_value, html) 131 | end) 132 | end 133 | 134 | @spec contain(AssertHTML.context(), Regex.t()) :: any() 135 | def contain({matcher, html}, %Regex{} = value) when is_binary(html) do 136 | raise_match(matcher, !Regex.match?(value, html), fn 137 | :assert -> 138 | [ 139 | message: "Value not matched.", 140 | left: value, 141 | right: html, 142 | expr: "assert_html(#{inspect(value)})" 143 | ] 144 | 145 | :refute -> 146 | [ 147 | message: "Value `#{inspect(value)}` matched, but shouldn't.", 148 | left: value, 149 | right: html, 150 | expr: "assert_html(#{inspect(value)})" 151 | ] 152 | end) 153 | end 154 | 155 | @spec contain(AssertHTML.context(), AssertHTML.html()) :: any() 156 | def contain({matcher, html}, value) when is_binary(html) and is_binary(value) do 157 | raise_match(matcher, !String.contains?(html, value), fn 158 | :assert -> 159 | [ 160 | message: "Value not found.", 161 | left: value, 162 | right: html, 163 | expr: "assert_html(#{inspect(value)})" 164 | ] 165 | 166 | :refute -> 167 | [ 168 | message: "Value `#{inspect(value)}` found, but shouldn't.", 169 | left: value, 170 | right: html, 171 | expr: "assert_html(#{inspect(value)})" 172 | ] 173 | end) 174 | end 175 | 176 | @spec match_attribute( 177 | assert_or_refute, 178 | AssertHTML.attribute_name(), 179 | AssertHTML.value(), 180 | binary() | nil, 181 | AssertHTML.html() 182 | ) :: no_return 183 | defp match_attribute(matcher, attribute, check_value, attr_value, html) 184 | 185 | # attribute should exists 186 | defp match_attribute(matcher, attribute, check_value, attr_value, html) when check_value in [nil, true, false] do 187 | raise_match(matcher, if(check_value, do: attr_value == nil, else: attr_value != nil), fn 188 | :assert -> 189 | if check_value, 190 | do: "Attribute `#{attribute}` should exists.\n\n\t#{html}\n", 191 | else: "Attribute `#{attribute}` shouldn't exists.\n\n\t#{html}\n" 192 | 193 | :refute -> 194 | if check_value, 195 | do: "Attribute `#{attribute}` shouldn't exists.\n\n\t#{html}\n", 196 | else: "Attribute `#{attribute}` should exists.\n\n\t#{html}\n" 197 | end) 198 | end 199 | 200 | # attribute should not exists 201 | defp match_attribute(matcher, attribute, _check_value, nil = _attr_value, html) do 202 | raise_match(matcher, matcher == :assert, fn 203 | _ -> "Attribute `#{attribute}` not found.\n\n\t#{html}\n" 204 | end) 205 | end 206 | 207 | defp match_attribute(matcher, attribute, %Regex{} = check_value, attr_value, html) do 208 | raise_match(matcher, !Regex.match?(check_value, attr_value), fn _ -> 209 | [ 210 | message: "Matching `#{attribute}` attribute failed.\n\n\t#{html}.\n", 211 | left: check_value, 212 | right: attr_value 213 | ] 214 | end) 215 | end 216 | 217 | defp match_attribute(matcher, "class", check_value, attr_value, html) do 218 | for check_class <- String.split(to_string(check_value), " ") do 219 | raise_match(matcher, !String.contains?(attr_value, check_class), fn 220 | :assert -> "Class `#{check_class}` not found in `#{attr_value}` class attribute\n\n\t#{html}\n" 221 | :refute -> "Class `#{check_class}` found in `#{attr_value}` class attribute\n\n\t#{html}\n" 222 | end) 223 | end 224 | end 225 | 226 | defp match_attribute(matcher, attribute, check_value, attr_value, html) do 227 | str_check_value = to_string(check_value) 228 | 229 | raise_match(matcher, str_check_value != attr_value, fn _ -> 230 | [ 231 | message: "Comparison `#{attribute}` attribute failed.\n\n\t#{html}.\n", 232 | left: str_check_value, 233 | right: attr_value 234 | ] 235 | end) 236 | end 237 | 238 | defp raise_match(check, condition, message_fn) when check in [:assert, :refute] do 239 | cond do 240 | check == :assert -> condition 241 | check == :refute -> !condition 242 | true -> false 243 | end 244 | |> if do 245 | message_or_args = message_fn.(check) 246 | 247 | if message_or_args do 248 | args = (is_list(message_or_args) && message_or_args) || [message: message_or_args] 249 | raise ExUnit.AssertionError, args 250 | end 251 | end 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /lib/assert_html/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule AssertHTML.Parser do 2 | @moduledoc false 3 | 4 | @typep html_element_tuple :: binary() | [any()] | tuple() 5 | @typep html_tree :: html_element_tuple 6 | 7 | @doc """ 8 | Find node 9 | 10 | ## Example 11 | 12 | Assuming that you have the following HTML: 13 | 14 | ```html 15 | 16 | 17 | 18 |Floki
20 | Github page 21 | philss 22 |36 | Click me 37 |
38 | } 39 | 40 | assert_html(html, "p") do 41 | assert_html("a", class: "link", text: "Click me", id: nil) 42 | end 43 | end 44 | 45 | test "use macro for defining context with selector and attributes" do 46 | html = ~S{ 47 |48 | Click me 49 |
50 | } 51 | 52 | assert_html(html, "p", class: "foo", id: "descr") do 53 | assert_html("a", class: "link", text: "Click me", id: nil) 54 | end 55 | end 56 | end 57 | 58 | describe "(check simple form)" do 59 | setup do 60 | [html: ~S{ 61 | 76 | }] 77 | end 78 | 79 | test "check elements", %{html: html} do 80 | html 81 | |> assert_html("form", class: "form", method: "post", action: "/session/login") do 82 | refute_html(".message") 83 | 84 | assert_html ".-email" do 85 | assert_html("label", text: "Email", for: "staticEmail", class: "col-form-label") 86 | 87 | assert_html("div input", 88 | type: "text", 89 | readonly: true, 90 | class: "form-control-plaintext", 91 | value: "email@example.com" 92 | ) 93 | end 94 | 95 | assert_html(".-password") do 96 | assert_html("label", text: "Password", for: "inputPassword") 97 | 98 | assert_html("div input", 99 | placeholder: "Password", 100 | type: "password", 101 | class: "form-control", 102 | id: "inputPassword", 103 | placeholder: "Password" 104 | ) 105 | end 106 | 107 | assert_html("button", type: "submit", class: "primary") 108 | end 109 | end 110 | end 111 | 112 | describe "(check contains)" do 113 | setup do 114 | [html: ~S{ 115 |Merry Christmas
Merry Christmas
") 93 | contain({:refute, html}, ~r"Peper") 94 | contain({:refute, html}, ~r"M & F}) == "M & F" 35 | end 36 | 37 | test "expect get emptry string if not exists" do 38 | assert text(~S{}) == "" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/assert_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AssertHTMLTest do 2 | use ExUnit.Case, async: true 3 | doctest AssertHTML, import: true 4 | import AssertHTML 5 | alias ExUnit.AssertionError 6 | 7 | describe "assert_html (check css selector)" do 8 | setup do 9 | [html: ~S{ 10 |
13 | Paragraph 14 |
15 |\n Paragraph\n
\n Paragraph\n
" 36 | end) 37 | end) 38 | 39 | assert result_html == 40 | "\n\n Paragraph\n
\n60 | Long Read Paragraph 61 |
62 | World 63 |