diff --git a/e2e/.gitignore b/e2e/.gitignore index 7cc57ce68..b2a82da61 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -24,4 +24,5 @@ e2e-*.tar # Temporary files, for example, from tests. /tmp/ -/out/ \ No newline at end of file +/out/ +.envrc diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 000000000..8305cb53b --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,54 @@ +ARG ELIXIR_VERSION=1.14.3 +ARG OTP_VERSION=25.2.3 +ARG ALPINE_VERSION=3.18.0 +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-alpine-${ALPINE_VERSION}" + +# Base stage with common dependencies +FROM ${BUILDER_IMAGE} AS base + +# Set Wallaby env var +ENV START_WALLABY=true + +# Install system dependencies including ChromeDriver +RUN apk update && \ + apk add --no-cache \ + build-base \ + git \ + python3 \ + curl \ + openssh \ + chromium \ + chromium-chromedriver \ + xvfb \ + bash \ + # Add additional dependencies that may be required + ttf-freefont \ + fontconfig \ + dbus \ + && apk add --no-cache --upgrade busybox busybox-binsh ssl_client + +# Set up Chrome for headless operation +ENV CHROME_BIN=/usr/bin/chromium-browser +ENV CHROME_PATH=/usr/lib/chromium/ +ENV CHROME_DRIVER_PATH=/usr/bin/chromedriver + +# Set up Elixir environment +WORKDIR /app + +# Install hex and rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# Copy and compile dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get && mix deps.compile + +# Copy application code +COPY . . + +# Set Wallaby to use Chrome in headless mode +ENV WALLABY_DRIVER=chrome +ENV WALLABY_CHROME_HEADLESS=true + +# Create directory for screenshots +RUN mkdir -p /app/out/screenshots diff --git a/e2e/Makefile b/e2e/Makefile index d76a7f632..05de92b03 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -1,4 +1,4 @@ -.PHONY: format test test.ui +.PHONY: format test test.ui console.bash test.ui.docker SHELL := /bin/bash export MIX_ENV ?= test @@ -17,12 +17,54 @@ ifeq (test.ui,$(MAKECMDGOALS)) export START_WALLABY=true endif +ifeq (test.ui.docker,$(MAKECMDGOALS)) + export START_WALLABY=true +endif + +ifeq (console.bash,$(MAKECMDGOALS)) + export START_WALLABY=true +endif + gcloud.auth: gcloud config set project $(GOOGLE_PROJECT_NAME) --quiet && gcloud auth login --cred-file=$(GOOGLE_APPLICATION_CREDENTIALS) console.ex: env.assert mix.prepare iex -S mix +# Start a bash shell in the Docker container +console.bash: env.assert + docker compose run --rm \ + -e START_WALLABY=true \ + -e MIX_ENV=$(MIX_ENV) \ + -e BASE_DOMAIN=$(BASE_DOMAIN) \ + -e CLOUD_TEST_ENV_PREFIX=$(CLOUD_TEST_ENV_PREFIX) \ + -e GITHUB_ORGANIZATION=$(GITHUB_ORGANIZATION) \ + -e GITHUB_REPOSITORY=$(GITHUB_REPOSITORY) \ + -e GITHUB_BRANCH=$(GITHUB_BRANCH) \ + -e SEMAPHORE_ORGANIZATION=$(SEMAPHORE_ORGANIZATION) \ + -e SEMAPHORE_BASE_DOMAIN=$(SEMAPHORE_BASE_DOMAIN) \ + -e SEMAPHORE_USER_EMAIL=$(SEMAPHORE_USER_EMAIL) \ + -e SEMAPHORE_API_TOKEN=$(SEMAPHORE_API_TOKEN) \ + -e SEMAPHORE_USER_PASSWORD=$(SEMAPHORE_USER_PASSWORD) \ + e2e-tests sh + +# Run UI tests in Docker +test.ui.docker: env.assert + docker compose run --rm \ + -e START_WALLABY=true \ + -e MIX_ENV=$(MIX_ENV) \ + -e BASE_DOMAIN=$(BASE_DOMAIN) \ + -e CLOUD_TEST_ENV_PREFIX=$(CLOUD_TEST_ENV_PREFIX) \ + -e GITHUB_ORGANIZATION=$(GITHUB_ORGANIZATION) \ + -e GITHUB_REPOSITORY=$(GITHUB_REPOSITORY) \ + -e GITHUB_BRANCH=$(GITHUB_BRANCH) \ + -e SEMAPHORE_ORGANIZATION=$(SEMAPHORE_ORGANIZATION) \ + -e SEMAPHORE_BASE_DOMAIN=$(SEMAPHORE_BASE_DOMAIN) \ + -e SEMAPHORE_USER_EMAIL=$(SEMAPHORE_USER_EMAIL) \ + -e SEMAPHORE_API_TOKEN=$(SEMAPHORE_API_TOKEN) \ + -e SEMAPHORE_USER_PASSWORD=$(SEMAPHORE_USER_PASSWORD) \ + e2e-tests sh -c "chromedriver --port=9515 --whitelisted-ips='' --url-base=/wd/hub & mix test $(if $(TEST_FILE),$(TEST_FILE),test/e2e/ui)" + format: SEMAPHORE_API_TOKEN="" \ SEMAPHORE_USER_PASSWORD="" \ diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 000000000..242b1fefb --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3" + +services: + e2e-tests: + build: + context: . + dockerfile: Dockerfile + platform: linux/arm64 # Use ARM64 platform for Apple Silicon Macs + shm_size: 2gb # Increase shared memory for Chrome + volumes: + - .:/app + - ./out/screenshots:/app/out/screenshots + environment: + - START_WALLABY + - WALLABY_DRIVER=chrome + - WALLABY_CHROME_HEADLESS=true + - MIX_ENV + # Chrome/Chromium configuration + - CHROME_BIN=/usr/bin/chromium-browser + - CHROME_PATH=/usr/lib/chromium/ + # Pass Semaphore environment variables from the host + - SEMAPHORE_API_TOKEN + - SEMAPHORE_USER_PASSWORD + - SEMAPHORE_BASE_DOMAIN + - SEMAPHORE_USER_EMAIL + - SEMAPHORE_ORGANIZATION + - CLOUD_TEST_ENV_PREFIX + - BASE_DOMAIN + - GITHUB_ORGANIZATION + - GITHUB_REPOSITORY + - GITHUB_BRANCH + - GOOGLE_PROJECT_NAME + - GOOGLE_APPLICATION_CREDENTIALS + ports: + - "9515:9515" # ChromeDriver port + command: > + sh -c "chromedriver --port=9515 --verbose --whitelisted-ips='' --url-base=/wd/hub & mix test --include browser" diff --git a/e2e/lib/e2e/clients/common.ex b/e2e/lib/e2e/clients/common.ex index 3caf1f1ae..006374a7a 100644 --- a/e2e/lib/e2e/clients/common.ex +++ b/e2e/lib/e2e/clients/common.ex @@ -45,13 +45,18 @@ defmodule E2E.Clients.Common do url = api_url(endpoint) timeout = Application.get_env(:e2e, :http_timeout, 30_000) - case HTTPoison.get(url, headers, timeout: timeout, recv_timeout: timeout, follow_redirect: true) do + case HTTPoison.get(url, headers, + timeout: timeout, + recv_timeout: timeout, + follow_redirect: true + ) do {:ok, response} -> {:ok, response} {:error, %HTTPoison.Error{reason: :timeout}} -> # Retry once on timeout Process.sleep(1000) + HTTPoison.get(url, headers, timeout: timeout, recv_timeout: timeout, follow_redirect: true) error -> diff --git a/e2e/lib/e2e/clients/job.ex b/e2e/lib/e2e/clients/job.ex index 21ac64197..5b42743b1 100644 --- a/e2e/lib/e2e/clients/job.ex +++ b/e2e/lib/e2e/clients/job.ex @@ -23,5 +23,4 @@ defmodule E2E.Clients.Job do {:error, reason} end end - end diff --git a/e2e/mix.exs b/e2e/mix.exs index 5a9b5e432..ebd21dac4 100644 --- a/e2e/mix.exs +++ b/e2e/mix.exs @@ -19,7 +19,24 @@ defmodule E2E.MixProject do ] end - defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(:test) do + base = ["lib", "test/support"] + if System.get_env("START_WALLABY") do + base + else + # Exclude ui_test_case.ex if Wallaby is not available + ["lib", "test/support"] + |> Enum.flat_map(fn path -> + if path == "test/support" do + Path.wildcard("test/support/*.ex") + |> Enum.reject(&(&1 =~ "ui_test_case.ex")) + else + [path] + end + end) + end + end + defp elixirc_paths(:dev), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] diff --git a/e2e/test/e2e/api/secrets_test.exs b/e2e/test/e2e/api/secrets_test.exs index 3182fb75c..e3d170963 100644 --- a/e2e/test/e2e/api/secrets_test.exs +++ b/e2e/test/e2e/api/secrets_test.exs @@ -32,7 +32,7 @@ defmodule E2E.API.SecretsTest do name = "test-project-#{:rand.uniform(1_000_000)}" repository_url = "git@github.com:#{organization}/#{repository}.git" {:ok, project} = Support.prepare_project(name, repository_url) - + on_exit(fn -> :ok = Project.delete(name) end) diff --git a/e2e/test/e2e/api/task_test.exs b/e2e/test/e2e/api/task_test.exs index 4b6dd8d57..fd1862dde 100644 --- a/e2e/test/e2e/api/task_test.exs +++ b/e2e/test/e2e/api/task_test.exs @@ -13,29 +13,32 @@ defmodule E2E.API.TaskTest do name = "test-project-#{:rand.uniform(1_000_000)}" # Create project with a task - task_definitions = [%{ - "name" => "test-task", - "status" => "ACTIVE", - "description" => "Test periodic task", - "at" => "0 * * * *", - "pipeline_file" => ".semaphore/semaphore.yml", - "branch" => "main", - "parameters" => [] - }] + task_definitions = [ + %{ + "name" => "test-task", + "status" => "ACTIVE", + "description" => "Test periodic task", + "at" => "0 * * * *", + "pipeline_file" => ".semaphore/semaphore.yml", + "branch" => "main", + "parameters" => [] + } + ] {:ok, created_project} = Support.prepare_project(name, repository_url, task_definitions) + on_exit(fn -> :ok = Project.delete(name) end) task = created_project["spec"]["tasks"] |> hd - {:ok, - project_id: created_project["metadata"]["id"], - task_id: task["id"] - } + {:ok, project_id: created_project["metadata"]["id"], task_id: task["id"]} end - test "run task with run_now and wait for completion", %{project_id: project_id, task_id: task_id} do + test "run task with run_now and wait for completion", %{ + project_id: project_id, + task_id: task_id + } do # Trigger run_now {:ok, response} = E2E.Clients.Task.run_now(task_id) diff --git a/e2e/test/e2e/api/workflow_test.exs b/e2e/test/e2e/api/workflow_test.exs index 893ead106..183549986 100644 --- a/e2e/test/e2e/api/workflow_test.exs +++ b/e2e/test/e2e/api/workflow_test.exs @@ -55,6 +55,7 @@ defmodule E2E.API.WorkflowTest do Enum.each(pipelines, fn pipeline -> if pipeline["result"] != "PASSED" do {:ok, job_ids} = Pipeline.failed_jobs_id(pipeline["ppl_id"]) + Enum.each(job_ids, fn job_id -> {:ok, events} = Job.events(job_id) Enum.each(events, fn event -> IO.puts(inspect(event)) end) diff --git a/e2e/test/e2e/ui/git_integrations_test.exs b/e2e/test/e2e/ui/git_integrations_test.exs new file mode 100644 index 000000000..3d161928c --- /dev/null +++ b/e2e/test/e2e/ui/git_integrations_test.exs @@ -0,0 +1,164 @@ +defmodule E2E.UI.GitIntegrationsTest do + use E2E.UI.UserTestCase + + def navigate_to_git_integrations(session, organization, base_domain) do + git_integrations_url = "https://#{organization}.#{base_domain}/settings/git_integrations/" + Logger.info("Navigating to Git Integrations page: #{git_integrations_url}") + + session + |> visit(git_integrations_url) + end + + describe "Git Integrations page" do + setup %{session: session, organization: organization, base_domain: base_domain} do + session = navigate_to_git_integrations(session, organization, base_domain) + {:ok, %{session: session}} + end + + test "has correct title and description", %{session: session} do + session + |> assert_has(Wallaby.Query.text("Git Integrations", count: 1)) + end + + test "has two main sections: Integrations and Connect new", %{session: session} do + session + |> assert_has( + Wallaby.Query.css(".bb.b--black-075.w-100-l.mb4.br3.shadow-3.bg-white", + count: 2 + ) + ) + |> assert_has( + Wallaby.Query.css(".bb.bw1.b--black-075.br3.br--top .flex.items-center .b", + text: "Integrations" + ) + ) + |> assert_has( + Wallaby.Query.css(".bb.bw1.b--black-075.br3.br--top .flex.items-center .b", + text: "Connect new" + ) + ) + end + + test "has at least one connected integration", %{session: session} do + session + |> assert_has( + Wallaby.Query.css(".bb.b--black-075.w-100-l .ph3.pv2.mv2", + minimum: 1 + ) + ) + + has_github_or_gitlab = + has?(session, Wallaby.Query.css("img[src*='icn-github.svg']")) || + has?(session, Wallaby.Query.css("img[src*='icn-gitlab.svg']")) + + assert has_github_or_gitlab, "Expected to find either GitHub or GitLab integration card" + + session + |> assert_has(Wallaby.Query.text("connected", minimum: 1)) + end + + test "has available integrations to connect", %{session: session} do + session + |> assert_has( + Wallaby.Query.css(".bb.b--black-075.w-100-l:nth-of-type(2) .ph3.pv2.mv2", + minimum: 1, + timeout: 10_000 + ) + ) + + available_for_connect = + has?(session, Wallaby.Query.css("img[src*='icn-github.svg']:not(.f6.gray ~ *)")) || + has?(session, Wallaby.Query.css("img[src*='icn-gitlab.svg']:not(.f6.gray ~ *)")) || + has?(session, Wallaby.Query.css("img[src*='icn-bitbucket.svg']")) + + assert available_for_connect, + "Expected to find at least one available integration to connect" + + session + |> assert_has(Wallaby.Query.link("Connect", minimum: 1)) + end + end + + describe "Integration details page" do + setup %{session: session, organization: organization, base_domain: base_domain} do + session = navigate_to_git_integrations(session, organization, base_domain) + + # Find the edit button and click it - we use a simpler direct approach + session + |> click( + Wallaby.Query.css(".material-symbols-outlined.f5.b.btn.pointer.pa1.btn-secondary.ml3", + count: 2, + at: 0 + ) + ) + |> assert_has(Wallaby.Query.text("Configuration parameters")) + + {:ok, %{session: session}} + end + + test "has correct page structure and navigation", %{session: session} do + session + |> assert_has(Wallaby.Query.link("← Back to Integration")) + |> assert_has(Wallaby.Query.css("h2.f3.f2-m.mb0")) + end + + test "shows connection status", %{session: session} do + session + |> assert_has(Wallaby.Query.text("GitHub App Connection")) + # Green circle indicator + |> assert_has(Wallaby.Query.css("circle[fill='#00a569']")) + end + + test "has required permissions section", %{session: session} do + session + |> assert_has(Wallaby.Query.text("Required Permissions")) + |> assert_has(Wallaby.Query.css("li", minimum: 5)) + end + + test "has remove connection section", %{session: session} do + session + |> assert_has(Wallaby.Query.text("Remove connection")) + |> assert_has(Wallaby.Query.text("Warning: Removing this integration")) + |> assert_has(Wallaby.Query.button("Delete")) + end + end + + describe "New integration setup page" do + setup %{session: session, organization: organization, base_domain: base_domain} do + session = navigate_to_git_integrations(session, organization, base_domain) + + session + |> click(Wallaby.Query.css("a.btn.btn-primary.btn-small", at: 0)) + + session + |> assert_has(Wallaby.Query.text("Integration Setup")) + + {:ok, %{session: session}} + end + + test "has correct page structure and navigation", %{session: session} do + session + |> assert_has(Wallaby.Query.link("← Back to Integration")) + |> assert_has(Wallaby.Query.text("Integration Setup")) + end + + test "has configuration parameters section", %{session: session} do + session + |> assert_has(Wallaby.Query.text("Configuration parameters")) + |> assert_has(Wallaby.Query.text("Callback URL")) + end + + test "has required permissions list", %{session: session} do + session + |> assert_has(Wallaby.Query.text("Required Permissions")) + |> assert_has(Wallaby.Query.css("li", minimum: 5)) + end + + test "has connect integration form", %{session: session} do + session + |> assert_has(Wallaby.Query.text_field("client_id")) + |> assert_has(Wallaby.Query.css("input[type='password'][name='client_secret']")) + |> assert_has(Wallaby.Query.button("Connect Integration")) + end + end +end diff --git a/e2e/test/e2e/ui/project_creation_test.exs b/e2e/test/e2e/ui/project_creation_test.exs new file mode 100644 index 000000000..4aa027219 --- /dev/null +++ b/e2e/test/e2e/ui/project_creation_test.exs @@ -0,0 +1,118 @@ +defmodule E2E.UI.ProjectCreationFlowTest do + use E2E.UI.UserTestCase + require Logger + + describe "Project Creation Flow" do + @tag timeout: 600_000 + test "complete project creation flow", %{session: session} do + Logger.info("Starting Project Creation Flow test") + + # Step 1: Navigate to project creation page + Logger.info("Step 1: Navigating to project creation page") + session = click(session, Wallaby.Query.link("Create new")) + + # Verify we're on the project creation page + Logger.info("Verifying project creation page") + session = assert_has(session, Wallaby.Query.css("h1", text: "Project type")) + + # Step 2: Select GitHub integration using a specific CSS selector + Logger.info("Step 2: Selecting GitHub integration") + session = click(session, Wallaby.Query.css(".f3.b", text: "GitHub")) + + # Take screenshot to see what happened + take_screenshot(session, name: "after_github_click") + + # Verify we're on the repository selection page + Logger.info("Verifying repository selection page") + session = assert_has(session, Wallaby.Query.css("h2", text: "Repository")) + + # Step 3: Search for repositories + Logger.info("Step 3: Searching for repositories") + + session = + fill_in(session, Wallaby.Query.fillable_field("Search repositories..."), with: "e2e-tests") + + # Give the search some time to complete + :timer.sleep(1000) + + session = assert_has(session, Wallaby.Query.css(".option")) + + # Try to find and click the "Choose" button if available + Logger.info("Clicking 'Choose' button") + session = click(session, Wallaby.Query.css(".green", text: "Choose")) + + # Take screenshot after repository selection + take_screenshot(session, name: "after_repository_selection") + # Give some time to check for duplicates + :timer.sleep(1000) + take_screenshot(session, name: "after_repository_selection_with_duplicates") + + session = maybe_enable_duplicate(session) + + Logger.info("Clicking create project button") + assert_has(session, Wallaby.Query.css("button.btn.btn-primary")) + click(session, Wallaby.Query.button("✓")) + + # Take screenshot after clicking continue + take_screenshot(session, name: "after_continue") + + :timer.sleep(15_000) + # Verify we moved to the analysis page and check for the analysis steps checklist + take_screenshot(session, name: "analysis_page") + + # wait for project creation to complete (until webhook readonly input is visible) + # This can take up to 15 seconds + Logger.info("Waiting for project creation to complete (up to 15 seconds)...") + + # click continue button + session = click(session, Wallaby.Query.link("Continue")) + + # Click on the "I want to configure this project from scratch" link + Logger.info("Clicking 'I want to configure this project from scratch' link") + + session = + click(session, Wallaby.Query.link("I want to configure this project from scratch")) + + # Take a screenshot after clicking the link + take_screenshot(session, name: "configure_from_scratch") + + # Click continue button again after selecting "configure from scratch" + Logger.info("Clicking Continue button again") + session = assert_has(session, Wallaby.Query.button("Continue")) + session = click(session, Wallaby.Query.button("Continue")) + + # Take a screenshot after clicking continue again + take_screenshot(session, name: "after_second_continue") + + # Click "Looks good, start →" button + Logger.info("Clicking 'Looks good, start →' button") + session = assert_has(session, Wallaby.Query.button("Looks good, start →")) + session = click(session, Wallaby.Query.button("Looks good, start →")) + + # Take a screenshot after clicking the start button + take_screenshot(session, name: "after_start_button") + + # Wait for 15 seconds to allow page transition + Logger.info("Waiting 15 seconds for page transition...") + :timer.sleep(15_000) + + # Verify the URL path starts with "/workflows/" + Logger.info("Verifying we are on the workflows page") + current_url = current_url(session) + + assert String.contains?(current_url, "/workflows/"), + "Expected URL to contain '/workflows/', but got: #{current_url}" + + # Take a final screenshot of the workflows page + take_screenshot(session, name: "workflows_page") + + Logger.info("Project Creation completed successfully, flow test is complete") + end + end + + defp maybe_enable_duplicate(session) do + if has?(session, Wallaby.Query.button("Make a duplicate project")) do + click(session, Wallaby.Query.button("Make a duplicate project")) + end + end +end diff --git a/e2e/test/e2e/ui/user_management_test.exs b/e2e/test/e2e/ui/user_management_test.exs new file mode 100644 index 000000000..88ad59cc1 --- /dev/null +++ b/e2e/test/e2e/ui/user_management_test.exs @@ -0,0 +1,192 @@ +defmodule E2E.UI.UserManagementTest do + use E2E.UI.UserTestCase, async: false + require Logger + + describe "User Management Page" do + setup %{base_url: base_url} do + on_exit(fn -> + {:ok, cleanup_session} = Wallaby.start_session() + cleanup_session = cleanup_session |> E2E.Support.UserAction.login() |> navigate_to_people_page(base_url) + remove_all_users(cleanup_session) + end) + :ok + end + + test "accessing and interacting with the People page", %{session: session, base_url: base_url} do + emails = random_emails(10) + + navigate_to_people_page(session, base_url) + |> then(fn session -> + assert_has(session, Wallaby.Query.css("div.b", text: "People")) + session + end) + |> click(Wallaby.Query.button("Add people")) + |> create_users(emails) + |> click(Wallaby.Query.button("Create Accounts")) + |> then(fn session -> + :timer.sleep(2_000) + session + end) + end + + test "can create a user and log in with it", %{session: session, base_url: base_url, login_url: login_url} do + emails = random_emails(1) + + session = + session + |> navigate_to_people_page(base_url) + |> assert_has(Wallaby.Query.css("div.b", text: "People")) + |> click(Wallaby.Query.button("Add people")) + |> then(fn s -> :timer.sleep(1000); s end) + |> create_users(emails) + |> click(Wallaby.Query.button("Create Accounts")) + |> then(fn s -> :timer.sleep(2000); s end) + + [user_cred] = extract_user_credentials(session) + + {:ok, login_session} = Wallaby.start_session() + + login_session + |> visit(login_url) + |> E2E.Support.UserAction.login(login_url, user_cred.email, user_cred.password) + |> E2E.Support.UserAction.change_password(hd(random_emails(1))) + end + + test "can create a user and find them in the people list", %{session: session, base_url: base_url} do + known_email = "knownuser@example.com" + session = + session + |> navigate_to_people_page(base_url) + |> assert_has(Wallaby.Query.css("div.b", text: "People")) + |> click(Wallaby.Query.button("Add people")) + |> then(fn s -> :timer.sleep(1000); s end) + |> create_users([known_email]) + |> click(Wallaby.Query.button("Create Accounts")) + |> then(fn s -> :timer.sleep(2000); s end) + + # Go back to People page if needed + session = navigate_to_people_page(session, base_url) + + # Find the member div containing the known email + find_member_scope_by_email(session, known_email) + |> click(Wallaby.Query.css("button.btn.btn-secondary span", text: "Edit")) + + session + |> then(fn scope -> + assert_has(scope, Wallaby.Query.text("Edit user")) + scope + end) + |> click(Wallaby.Query.button("Reset password")) + |> then(fn scope -> + assert_has(scope, Wallaby.Query.text("Are you sure you want to reset the password?")) + scope + end) + |> click(Wallaby.Query.button("Reset password")) + |> then(fn scope -> + assert_has(scope, Wallaby.Query.text("New temporary password")) + scope + end) + |> then(fn scope -> + # Find the Admin label and click it + admin_label = find(scope, Wallaby.Query.css("label.pointer", text: "Admin")) + Wallaby.Element.click(admin_label) + scope + end) + |> click(Wallaby.Query.button("Save changes")) + |> then(fn scope -> + assert_has(scope, Wallaby.Query.text("Role successfully assigned")) + scope + end) + |> click(Wallaby.Query.button("Cancel")) + + # confirm that member is now admin + find_member_scope_by_email(session, known_email) + |> assert_has(Wallaby.Query.css("span.f6.normal", text: "Admin")) + end + end + + @doc """ + Helper function to navigate to the People page + """ + def navigate_to_people_page(session, base_url) do + visit(session, "#{base_url}/people") + end + + defp remove_all_users(session) do + remove_query = Wallaby.Query.css("button.btn.btn-secondary[name=remove-btn]") + do_remove_all_users(session, remove_query) + end + + defp do_remove_all_users(session, query) do + case all(session, query) do + [btn | _] -> + Wallaby.Element.click(btn) + :timer.sleep(500) + do_remove_all_users(session, query) + [] -> + session + end + end + + # Helper: create users by filling in emails and submitting + defp create_users(session, emails) do + Enum.reduce(Enum.with_index(emails, 1), session, fn {email, i}, session_acc -> + email_fields = all(session_acc, Wallaby.Query.fillable_field("Enter email address")) + email_field_index = length(email_fields) - 1 + updated_session = + if email_field_index >= 0 do + fill_in( + session_acc, + Wallaby.Query.fillable_field("Enter email address", count: :any, at: email_field_index), + with: email + ) + else + fill_in(session_acc, Wallaby.Query.fillable_field("Enter email address"), with: email) + end + :timer.sleep(300) + updated_session + end) + end + + # Helper: extract credentials from confirmation blocks + defp extract_user_credentials(session) do + account_blocks = all(session, Wallaby.Query.css(".email-input-group.mb3")) + {credentials, failures} = + Enum.reduce(account_blocks, {[], 0}, fn block, {acc, fails} -> + try do + email_element = find(block, Wallaby.Query.css(".f4")) + email = Wallaby.Element.text(email_element) + password_element = find(block, Wallaby.Query.css("code.f6")) + password = Wallaby.Element.text(password_element) + {[ %{email: email, password: password} | acc ], fails} + rescue + Wallaby.QueryError -> + {acc, fails + 1} + end + end) + if failures > length(account_blocks)/2 do + flunk("Failed to extract credentials from more than 50% of blocks (#{failures} failures)") + end + Enum.reverse(credentials) + end + + defp find_member_scope_by_email(session, email) do + username = String.split(email, "@") |> hd() + + all(session, Wallaby.Query.css("div#member")) + |> Enum.find(fn div -> + has?(div, Wallaby.Query.css("a", text: username)) + end) + end + + defp random_emails(n) do + random_str = Enum.map(1..5, fn _ -> Enum.random('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') end) |> to_string() + Enum.map(1..n, fn i -> + random_email(random_str, i) + end) + end + # Helper: generate a random email + defp random_email(random_str, n) do + "#{random_str}#{n}@example.com" + end +end diff --git a/e2e/test/support/ui_test_case.ex b/e2e/test/support/ui_test_case.ex new file mode 100644 index 000000000..7e67806ee --- /dev/null +++ b/e2e/test/support/ui_test_case.ex @@ -0,0 +1,75 @@ +defmodule E2E.UI.UserTestCase do + @moduledoc """ + A custom ExUnit case for UI tests requiring a logged-in user. + + This module automatically: + - Tags tests with :user and :browser + - Sets up Wallaby browser session + - Performs user login before each test + """ + + use ExUnit.CaseTemplate + require Wallaby.Browser + import Wallaby.Browser + import E2E.Support.UserAction + require Logger + + using do + quote do + use Wallaby.DSL + require Wallaby.Browser + import Wallaby.Browser + require Logger + + @moduletag :user + @moduletag :browser + @moduletag timeout: 300_000 + end + end + + setup_all do + Application.put_env(:wallaby, :js_errors, false) + :ok + end + + setup do + {:ok, session} = Wallaby.start_session(js_errors: false) + + base_domain = Application.get_env(:e2e, :semaphore_base_domain) + root_email = Application.get_env(:e2e, :semaphore_root_email) + root_password = Application.get_env(:e2e, :semaphore_root_password) + organization = Application.get_env(:e2e, :semaphore_organization) + + base_url = "https://#{organization}.#{base_domain}" + login_url = "https://id.#{base_domain}/login" + + try do + # Fill in login form and authenticate + logged_in_session = login(session, login_url, root_email, root_password) + + assert current_url(logged_in_session) == + "https://#{organization}.#{base_domain}/get_started/" + + {:ok, session: logged_in_session, organization: organization, base_domain: base_domain, base_url: base_url, login_url: login_url} + rescue + e in Wallaby.ExpectationNotMetError -> + # Take screenshot of the error state + take_screenshot(session, name: "login_failure") + # Log the current URL and HTML source for debugging + # Attempt to capture some of the page source + Logger.error("Login failed! Current URL: #{current_url(session)}") + + html_source = + try do + session + |> execute_script("return document.documentElement.outerHTML") + |> String.slice(0, 500) + rescue + _ -> "Could not retrieve page source" + end + + Logger.error("Page source snippet: #{html_source}...") + reraise e, __STACKTRACE__ + end + end +end diff --git a/e2e/test/support/user_action.ex b/e2e/test/support/user_action.ex new file mode 100644 index 000000000..281dcdb05 --- /dev/null +++ b/e2e/test/support/user_action.ex @@ -0,0 +1,35 @@ +defmodule E2E.Support.UserAction do + import Wallaby.Browser + import Wallaby.Query + + def login(session) do + base_domain = Application.get_env(:e2e, :semaphore_base_domain) + root_email = Application.get_env(:e2e, :semaphore_root_email) + root_password = Application.get_env(:e2e, :semaphore_root_password) + + login_url = "https://id.#{base_domain}/login" + + login(session, login_url, root_email, root_password) + end + + def login(session, login_url, email, password) do + session + |> visit(login_url) + |> then(fn s -> + # Verify login form exists + has?(s, css("#kc-form-login")) + s + end) + |> fill_in(text_field("username"), with: email) + |> fill_in(text_field("password"), with: password) + |> click(css("#kc-login")) + + end + + def change_password(session, password) do + session + |> fill_in(text_field("password-new"), with: password) + |> fill_in(text_field("password-confirm"), with: password) + |> click(button("Submit")) + end +end