tag containing the text
+ manage_agents = auth_page.locator('a', has_text='Manage Agents')
+ expect(manage_agents).to_be_visible()
+
+ # The href attribute should reference the /agents path
+ href = manage_agents.get_attribute('href')
+ assert href is not None and href.endswith('/agents'), (
+ f"'Manage Agents' link href expected to end with '/agents', got: '{href}'"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 3. "Manage Operations" link is present and points to /operations
+# ---------------------------------------------------------------------------
+
+def test_home_has_manage_operations_link(auth_page: Page, caldera_server: str) -> None:
+ """
+ The home page must contain a visible link or button with the text
+ 'Manage Operations' that resolves to the /operations route.
+ """
+ auth_page.goto(caldera_server + '/')
+ auth_page.wait_for_load_state('networkidle')
+
+ manage_ops = auth_page.locator('a', has_text='Manage Operations')
+ expect(manage_ops).to_be_visible()
+
+ href = manage_ops.get_attribute('href')
+ assert href is not None and href.endswith('/operations'), (
+ f"'Manage Operations' link href expected to end with '/operations', got: '{href}'"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 4. Backend config endpoint returns a valid response (API sanity check)
+# ---------------------------------------------------------------------------
+
+def test_home_api_config_matches_display(
+ api_session: requests.Session, caldera_server: str
+) -> None:
+ """
+ GET /api/v2/config/main — the same endpoint HomeView.vue fetches on
+ mount — must respond with HTTP 200 and a non-empty JSON object.
+
+ This validates that the data underpinning the home page dashboard is
+ accessible, without coupling the test to specific config key names.
+ """
+ response = api_session.get(f'{caldera_server}/api/v2/config/main')
+
+ assert response.status_code == 200, (
+ f"Expected HTTP 200 from /api/v2/config/main, got {response.status_code}"
+ )
+
+ config = response.json()
+ assert isinstance(config, dict), (
+ f"Expected a JSON object from /api/v2/config/main, got: {type(config)}"
+ )
+ assert len(config) > 0, (
+ "Config response was an empty dict — expected at least one key"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 5. Clicking "Manage Agents" navigates to /agents
+# ---------------------------------------------------------------------------
+
+def test_navigation_to_agents_from_home(auth_page: Page, caldera_server: str) -> None:
+ """
+ Clicking the 'Manage Agents' link from the home page should perform
+ a client-side Vue router navigation that changes the URL to end with
+ /agents.
+ """
+ auth_page.goto(caldera_server + '/')
+ auth_page.wait_for_load_state('networkidle')
+
+ # Click the link and wait for the SPA route change to settle
+ auth_page.locator('a', has_text='Manage Agents').click()
+ auth_page.wait_for_load_state('networkidle')
+
+ assert auth_page.url.endswith('/agents'), (
+ f"Expected URL to end with '/agents' after clicking 'Manage Agents', "
+ f"but current URL is: {auth_page.url}"
+ )
diff --git a/tests/e2e/test_login.py b/tests/e2e/test_login.py
new file mode 100644
index 0000000..20a2252
--- /dev/null
+++ b/tests/e2e/test_login.py
@@ -0,0 +1,158 @@
+"""
+E2E tests for the Caldera login page (LoginView.vue).
+
+These tests cover the unauthenticated login flow, credential validation,
+page structure, and redirect behaviour for already-authenticated users.
+
+Tests that exercise the login form itself (1–4) use the bare `page` fixture
+so no auth cookies are present. Test 5 uses `auth_page` to verify that an
+already-authenticated session is bounced away from /login.
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_login.py -v --browser chromium
+"""
+
+import pytest
+from playwright.sync_api import expect, Page
+
+
+# ---------------------------------------------------------------------------
+# 1. Unauthenticated visit to / redirects to /login and page is well-formed
+# ---------------------------------------------------------------------------
+
+def test_login_page_loads(page: Page, caldera_server: str) -> None:
+ """
+ An unauthenticated GET to / should redirect to /login.
+ The login page must contain a username input, a password input,
+ and a 'Log In' submit button.
+ """
+ # Navigate to the root; the Vue router should redirect to /login
+ page.goto(caldera_server + '/')
+ page.wait_for_load_state('networkidle')
+
+ # Confirm we landed on the login route
+ assert '/login' in page.url, (
+ f"Expected redirect to /login but current URL is {page.url}"
+ )
+
+ # Username field
+ username_input = page.locator('input[type="text"][placeholder="username"]')
+ expect(username_input).to_be_visible()
+
+ # Password field
+ password_input = page.locator('input[type="password"][placeholder="password"]')
+ expect(password_input).to_be_visible()
+
+ # Submit button
+ login_button = page.locator('button', has_text='Log In')
+ expect(login_button).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Valid credentials log in and redirect away from /login
+# ---------------------------------------------------------------------------
+
+def test_login_with_valid_credentials(page: Page, caldera_server: str) -> None:
+ """
+ Submitting correct credentials (admin/admin) should navigate the user
+ away from /login (typically to /). No error text should be visible.
+ """
+ page.goto(caldera_server + '/login')
+ page.wait_for_load_state('networkidle')
+
+ # Fill the form
+ page.locator('input[type="text"][placeholder="username"]').fill('admin')
+ page.locator('input[type="password"][placeholder="password"]').fill('admin')
+
+ # Click the login button and wait for navigation to settle
+ page.locator('button', has_text='Log In').click()
+
+ # Wait for the Vue router to navigate away from /login after successful auth
+ try:
+ page.wait_for_url(lambda url: '/login' not in url, timeout=10000)
+ except Exception:
+ page.wait_for_load_state('networkidle')
+
+ # After a successful login the URL must no longer be /login
+ assert '/login' not in page.url, (
+ f"Login appeared to fail — still on {page.url}"
+ )
+
+ # The error container should be empty / invisible
+ error_paragraph = page.locator('.has-text-danger p')
+ # Either the element is absent from the DOM, or its text content is blank
+ if error_paragraph.count() > 0:
+ assert error_paragraph.inner_text().strip() == '', (
+ f"Unexpected login error shown: '{error_paragraph.inner_text()}'"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 3. Invalid credentials stay on /login and show an error message
+# ---------------------------------------------------------------------------
+
+def test_login_with_invalid_credentials(page: Page, caldera_server: str) -> None:
+ """
+ Submitting a wrong password should keep the user on /login and render
+ a non-empty error message inside `.has-text-danger p`.
+ """
+ page.goto(caldera_server + '/login')
+ page.wait_for_load_state('networkidle')
+
+ # Fill with a bad password
+ page.locator('input[type="text"][placeholder="username"]').fill('admin')
+ page.locator('input[type="password"][placeholder="password"]').fill('wrongpassword')
+ page.locator('button', has_text='Log In').click()
+ page.wait_for_load_state('networkidle')
+
+ # Must still be on the login page
+ assert '/login' in page.url, (
+ f"Expected to remain on /login after bad credentials, but URL is {page.url}"
+ )
+
+ # Error paragraph must be visible and non-empty.
+ # LoginView.vue renders: div.has-text-danger > p {{ loginError }}
+ # The always exists in the DOM; after a failed login loginError is non-empty.
+ error_paragraph = page.locator('.has-text-danger p')
+ expect(error_paragraph).to_be_visible()
+ # Wait briefly for the Vue reactive update to set the error text
+ page.wait_for_timeout(500)
+ assert error_paragraph.inner_text().strip() != '', (
+ "Expected a non-empty error message after failed login, but got empty text"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 4. Login page displays the Caldera logo
+# ---------------------------------------------------------------------------
+
+def test_login_page_has_caldera_logo(page: Page, caldera_server: str) -> None:
+ """
+ The login page should render an with alt="Caldera Logo".
+ """
+ page.goto(caldera_server + '/login')
+ page.wait_for_load_state('networkidle')
+
+ logo = page.locator('img[alt="Caldera Logo"]')
+ expect(logo).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 5. Already-authenticated user visiting /login is redirected away
+# ---------------------------------------------------------------------------
+
+def test_authenticated_user_redirected_from_login(
+ auth_page: Page, caldera_server: str
+) -> None:
+ """
+ When a browser session already holds valid auth cookies, navigating to
+ /login should immediately redirect to / (or another authenticated route)
+ rather than rendering the login form.
+ """
+ auth_page.goto(caldera_server + '/login')
+ auth_page.wait_for_load_state('networkidle')
+
+ # The Vue router guards should have pushed the user away from /login
+ assert '/login' not in auth_page.url, (
+ f"Authenticated user was not redirected from /login; current URL: {auth_page.url}"
+ )
diff --git a/tests/e2e/test_obfuscators.py b/tests/e2e/test_obfuscators.py
new file mode 100644
index 0000000..e473c1d
--- /dev/null
+++ b/tests/e2e/test_obfuscators.py
@@ -0,0 +1,71 @@
+"""
+E2E tests for the Caldera Obfuscators page (ObfuscatorsView.vue).
+
+Obfuscators modify commands an agent runs to avoid detection.
+Caldera ships with 'plain-text' and 'base64' built-in.
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_obfuscators.py -v --browser chromium
+"""
+
+from playwright.sync_api import Page, expect
+
+
+def test_obfuscators_page_heading(auth_page: Page, base_url: str) -> None:
+ """h2 'Obfuscators' is visible after navigating to /obfuscators."""
+ auth_page.goto(base_url + '/obfuscators')
+ auth_page.wait_for_load_state('networkidle')
+ expect(auth_page.locator('h2', has_text='Obfuscators')).to_be_visible()
+
+
+def test_obfuscators_description_visible(auth_page: Page, base_url: str) -> None:
+ """A descriptive paragraph about obfuscation is visible."""
+ auth_page.goto(base_url + '/obfuscators')
+ auth_page.wait_for_load_state('networkidle')
+ expect(auth_page.locator('p').first).to_be_visible()
+
+
+def test_obfuscators_api_returns_builtins(api_session, base_url: str) -> None:
+ """GET /api/v2/obfuscators returns at least 'plain-text' and 'base64'."""
+ resp = api_session.get(f'{base_url}/api/v2/obfuscators')
+ assert resp.status_code == 200
+ names = [o.get('name', '') for o in resp.json()]
+ assert 'plain-text' in names, f"'plain-text' not in obfuscators: {names}"
+ assert 'base64' in names, f"'base64' not in obfuscators: {names}"
+
+
+def test_obfuscators_count_matches_ui(auth_page: Page, api_session, base_url: str) -> None:
+ """The number of obfuscator items in the UI matches the API count."""
+ resp = api_session.get(f'{base_url}/api/v2/obfuscators')
+ assert resp.status_code == 200
+ api_obfuscators = resp.json()
+
+ auth_page.goto(base_url + '/obfuscators')
+ auth_page.wait_for_load_state('networkidle')
+
+ # Each obfuscator should appear as a named element somewhere in the page
+ for obf in api_obfuscators:
+ name = obf.get('name', '')
+ if name:
+ expect(auth_page.get_by_text(name, exact=False)).to_be_visible()
+
+
+def test_plain_text_obfuscator_displayed(auth_page: Page, base_url: str) -> None:
+ """The 'plain-text' obfuscator name is visible in the page."""
+ auth_page.goto(base_url + '/obfuscators')
+ auth_page.wait_for_load_state('networkidle')
+ expect(auth_page.get_by_text('plain-text', exact=False)).to_be_visible()
+
+
+def test_base64_obfuscator_displayed(auth_page: Page, base_url: str) -> None:
+ """The 'base64' obfuscator name is visible in the page."""
+ auth_page.goto(base_url + '/obfuscators')
+ auth_page.wait_for_load_state('networkidle')
+ expect(auth_page.get_by_text('base64', exact=False)).to_be_visible()
+
+
+def test_obfuscators_no_create_button(auth_page: Page, base_url: str) -> None:
+ """Obfuscators are registered via Python modules — no 'Create' button exists."""
+ auth_page.goto(base_url + '/obfuscators')
+ auth_page.wait_for_load_state('networkidle')
+ expect(auth_page.get_by_role('button', name='New Obfuscator')).to_have_count(0)
diff --git a/tests/e2e/test_objectives.py b/tests/e2e/test_objectives.py
new file mode 100644
index 0000000..59aa741
--- /dev/null
+++ b/tests/e2e/test_objectives.py
@@ -0,0 +1,271 @@
+"""
+Playwright E2E tests for the Caldera Objectives UI view (/objectives).
+
+These tests verify the page structure, dropdown selector, detail panel,
+API/UI consistency, and the create-objective flow for the ObjectivesView
+component. Any objectives created during tests are deleted via the API in
+cleanup to keep the instance state clean.
+
+Fixtures used (provided by conftest.py):
+ caldera_server (session) — base URL string
+ api_session (session) — authenticated requests.Session
+ auth_page (function) — Playwright page with auth cookies, not yet navigated
+ base_url (function) — base URL string
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_objectives.py -v --browser chromium
+"""
+
+import pytest
+from playwright.sync_api import expect, Page
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def navigate_to_objectives(page: Page, base_url: str) -> None:
+ """Navigate to the /objectives route and wait for network activity to settle."""
+ page.goto(f"{base_url}/objectives")
+ page.wait_for_load_state("networkidle")
+
+
+def get_objectives_from_api(api_session, caldera_server: str) -> list:
+ """Return the list of objective objects from the REST API."""
+ resp = api_session.get(f"{caldera_server}/api/v2/objectives")
+ assert resp.status_code == 200, (
+ f"GET /api/v2/objectives returned HTTP {resp.status_code}"
+ )
+ return resp.json()
+
+
+def delete_objective_via_api(api_session, caldera_server: str, objective_id: str) -> None:
+ """Delete a single objective by id via the REST API (best-effort cleanup)."""
+ api_session.delete(f"{caldera_server}/api/v2/objectives/{objective_id}")
+
+
+# ---------------------------------------------------------------------------
+# 1. h2 heading "Objectives" is visible
+# ---------------------------------------------------------------------------
+
+def test_objectives_page_heading(auth_page: Page, base_url: str) -> None:
+ """
+ Navigating to /objectives must render an
element whose text is
+ "Objectives", confirming the correct view has been routed to.
+ """
+ navigate_to_objectives(auth_page, base_url)
+
+ heading = auth_page.locator("h2", has_text="Objectives")
+ expect(heading).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Introductory description paragraph is visible
+# ---------------------------------------------------------------------------
+
+def test_objectives_description_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The ObjectivesView does not have a static description paragraph.
+ Instead it shows an h2 heading and a source selector dropdown.
+ We verify the page loaded correctly by checking the h2 and the
+ element for objectives are visible.
+ """
+ navigate_to_objectives(auth_page, base_url)
+
+ # The h2 heading must be visible
+ heading = auth_page.locator("h2", has_text="Objectives")
+ expect(heading).to_be_visible()
+
+ # The objective selector must also be present
+ selector = auth_page.locator("select")
+ expect(selector.first).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 3. "New Objective" button is visible
+# ---------------------------------------------------------------------------
+
+def test_new_objective_button_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The primary call-to-action button labelled "New Objective" must be present
+ and visible in the left toolbar column alongside the selector dropdown.
+ """
+ navigate_to_objectives(auth_page, base_url)
+
+ new_btn = auth_page.locator("button", has_text="New Objective")
+ expect(new_btn).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 4. UI objective count matches API response count
+# ---------------------------------------------------------------------------
+
+def test_objectives_api_count_matches_ui(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ The number of objective entries available for selection in the dropdown
+ or list must equal the count returned by GET /api/v2/objectives.
+
+ The dropdown renders one per objective (excluding any blank
+ placeholder option). When zero objectives exist the selector should be
+ empty; when objectives exist their count must match.
+ """
+ objectives = get_objectives_from_api(api_session, caldera_server)
+ api_count = len(objectives)
+
+ navigate_to_objectives(auth_page, base_url)
+
+ # The objective selector is a element inside the .column.is-4 column.
+ # Count elements, ignoring the blank placeholder (value="").
+ select_el = auth_page.locator("select")
+ non_blank_options = select_el.locator("option:not([value=''])")
+ ui_count = non_blank_options.count()
+
+ assert ui_count == api_count, (
+ f"UI shows {ui_count} objective option(s) but API returned {api_count}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 5. First objective's name appears in the selector / dropdown
+# ---------------------------------------------------------------------------
+
+def test_objective_names_in_selector(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ When at least one objective exists, the name of the first objective
+ returned by GET /api/v2/objectives must appear as a selectable option
+ inside the left-column dropdown/selector, confirming that the Vue
+ component populates the selector from API data.
+
+ Skipped when no objectives are present.
+ """
+ objectives = get_objectives_from_api(api_session, caldera_server)
+
+ if not objectives:
+ pytest.skip("No objectives present — cannot verify selector contents.")
+
+ first_name = objectives[0].get("name", "")
+ if not first_name:
+ pytest.skip("First objective has no name field — skipping.")
+
+ navigate_to_objectives(auth_page, base_url)
+
+ # The name should appear as an inside the element.
+ select_el = auth_page.locator("select")
+ name_option = select_el.locator(f"option", has_text=first_name)
+ expect(name_option.first).to_be_attached()
+
+
+# ---------------------------------------------------------------------------
+# 6. Clicking "New Objective" creates a new entry; clean up via API
+# ---------------------------------------------------------------------------
+
+def test_new_objective_creates_entry(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ Clicking the "New Objective" button should trigger a POST to
+ /api/v2/objectives and render the new entry in the selector.
+
+ The test records the objective count before and after clicking, asserts
+ an increase of exactly one, then deletes the newly created objective via
+ the API to restore the original state.
+ """
+ # Record the count of objectives before the action.
+ objectives_before = get_objectives_from_api(api_session, caldera_server)
+ count_before = len(objectives_before)
+ before_ids = {obj["id"] for obj in objectives_before}
+
+ navigate_to_objectives(auth_page, base_url)
+
+ new_btn = auth_page.locator("button", has_text="New Objective")
+ expect(new_btn).to_be_visible()
+ new_btn.click()
+
+ # Wait for the network request triggered by the button click to complete.
+ auth_page.wait_for_load_state("networkidle")
+
+ # Verify the API now returns one more objective.
+ objectives_after = get_objectives_from_api(api_session, caldera_server)
+ count_after = len(objectives_after)
+
+ assert count_after == count_before + 1, (
+ f"Expected {count_before + 1} objectives after creation but found {count_after}"
+ )
+
+ # Identify the newly created objective by finding the id that wasn't there before.
+ after_ids = {obj["id"] for obj in objectives_after}
+ new_ids = after_ids - before_ids
+
+ # Clean up: delete every objective that was created during this test.
+ for new_id in new_ids:
+ delete_objective_via_api(api_session, caldera_server, new_id)
+
+ # Confirm restoration.
+ objectives_final = get_objectives_from_api(api_session, caldera_server)
+ assert len(objectives_final) == count_before, (
+ f"Cleanup failed: expected {count_before} objectives but found {len(objectives_final)}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 7. Selecting an objective shows its detail panel
+# ---------------------------------------------------------------------------
+
+def test_selecting_objective_shows_details(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ When at least one objective exists, clicking (selecting) the first
+ objective in the dropdown should populate the right-hand detail panel
+ with the objective's name, description, and goals table/section.
+
+ The detail panel is rendered in the right column (.column.is-9) and
+ becomes populated once the Vue reactive selection propagates.
+
+ Skipped when no objectives are present.
+ """
+ objectives = get_objectives_from_api(api_session, caldera_server)
+
+ if not objectives:
+ pytest.skip("No objectives present — cannot verify detail panel.")
+
+ first_objective = objectives[0]
+ first_name = first_objective.get("name", "")
+
+ navigate_to_objectives(auth_page, base_url)
+
+ # The objective element is in the .column.is-4 center column.
+ select_el = auth_page.locator("select")
+ expect(select_el.first).to_be_visible()
+
+ # Select the option whose text matches the first objective's name.
+ if first_name:
+ select_el.first.select_option(label=first_name)
+ else:
+ # If name is blank, select by index (skip placeholder at 0).
+ select_el.first.select_option(index=1)
+
+ # Wait for the Vue reactive update to propagate.
+ auth_page.wait_for_load_state("networkidle")
+
+ # After selection the detail section (v-if="selectedObjective.id") should appear.
+ # ObjectivesView renders an h3 with the objective name and a GoalsTable component.
+ # Assert the Save button is visible (only rendered when isEditingNameDesc is true
+ # or via the edit button). Actually, the Save button only appears after clicking edit.
+ # Instead, assert the h3 with the objective name is visible in the detail area.
+ if first_name:
+ name_heading = auth_page.locator("h3", has_text=first_name)
+ expect(name_heading.first).to_be_visible()
+ else:
+ # No name — just check that the detail content section is visible
+ detail_content = auth_page.locator(".content")
+ expect(detail_content.first).to_be_visible()
+
+ # The goals section must also be visible (GoalsTable component renders a table).
+ # Accept a , , or any element within the .mt-5 goals container.
+ goals_section = auth_page.locator(".mt-5")
+ expect(goals_section.first).to_be_visible()
diff --git a/tests/e2e/test_operations.py b/tests/e2e/test_operations.py
new file mode 100644
index 0000000..fffc348
--- /dev/null
+++ b/tests/e2e/test_operations.py
@@ -0,0 +1,213 @@
+"""
+Playwright E2E tests for the Caldera Operations UI view (/operations).
+
+These tests verify the structure, visibility, and basic interactivity of
+the Operations page in the Magma frontend (Vue 3 SPA). All tests are
+independent and leave no permanent state — the operations list is observed
+but never mutated.
+
+Fixtures used (provided by conftest.py):
+ caldera_server (session) — base URL string
+ api_session (session) — authenticated requests.Session
+ auth_page (function) — Playwright page with auth cookies, not yet navigated
+ base_url (function) — base URL string
+"""
+
+import pytest
+from playwright.sync_api import expect, Page
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def navigate_to_operations(page: Page, base_url: str) -> None:
+ """Navigate to the /operations route and wait for network activity to settle."""
+ page.goto(f"{base_url}/operations")
+ page.wait_for_load_state("networkidle")
+
+
+def get_operations_from_api(api_session, caldera_server: str) -> list:
+ """Return the list of operation objects from the REST API."""
+ resp = api_session.get(f"{caldera_server}/api/v2/operations")
+ assert resp.status_code == 200, (
+ f"GET /api/v2/operations returned HTTP {resp.status_code}"
+ )
+ return resp.json()
+
+
+def get_operation_select(page: Page):
+ """
+ Return the element used to choose an operation.
+
+ OperationsView uses a native (not a Bulma dropdown) to list
+ available operations. The select element is inside the .column.is-4
+ center column.
+ """
+ select_el = page.locator(".columns .column.is-4 select")
+ expect(select_el.first).to_be_visible()
+ return select_el.first
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+def test_operations_page_heading(auth_page: Page, base_url: str) -> None:
+ """
+ The on the Operations page must contain the text "Operations".
+ This confirms the correct view is rendered by the Vue router.
+ """
+ navigate_to_operations(auth_page, base_url)
+
+ heading = auth_page.locator("h2")
+ expect(heading).to_be_visible()
+ expect(heading).to_contain_text("Operations")
+
+
+def test_new_operation_button_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The "New Operation" primary button must be present and visible in the
+ toolbar area next to the operation selector.
+ """
+ navigate_to_operations(auth_page, base_url)
+
+ new_op_btn = auth_page.get_by_role("button", name="New Operation")
+ expect(new_op_btn).to_be_visible()
+
+
+def test_operation_selector_dropdown_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The operation selector must be present and visible.
+
+ OperationsView uses a native element (not a Bulma dropdown button)
+ to list available operations. The placeholder has the text
+ "Select an operation" and value="".
+ """
+ navigate_to_operations(auth_page, base_url)
+
+ # The selector is a element in the center column
+ select_el = auth_page.locator(".columns .column.is-4 select")
+ expect(select_el.first).to_be_visible()
+
+ # The placeholder option should be present
+ placeholder = select_el.first.locator('option[value=""]')
+ expect(placeholder).to_be_attached()
+
+
+def test_new_operation_modal_opens(auth_page: Page, base_url: str) -> None:
+ """
+ Clicking "New Operation" should open a modal overlay that contains at
+ minimum a text input field for the operation name.
+
+ The Bulma modal becomes active by receiving the `is-active` class.
+ """
+ navigate_to_operations(auth_page, base_url)
+
+ new_op_btn = auth_page.get_by_role("button", name="New Operation")
+ expect(new_op_btn).to_be_visible()
+ new_op_btn.click()
+
+ # The Bulma modal gains `is-active` when opened
+ modal = auth_page.locator(".modal.is-active")
+ try:
+ expect(modal).to_be_visible(timeout=5000)
+ except Exception:
+ # Fallback: accept any visible dialog/overlay containing an input
+ modal = auth_page.locator("[class*='modal'], [role='dialog']")
+ expect(modal).to_be_visible(timeout=3000)
+
+ # The modal must contain at least one text input (operation name field)
+ name_input = modal.locator("input[type='text'], input:not([type])")
+ expect(name_input.first).to_be_visible()
+
+
+def test_operations_api_count_matches_dropdown(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ The number of items shown in the operation selector must equal
+ the count returned by GET /api/v2/operations.
+
+ Steps:
+ 1. Fetch operations from the API to get the expected count.
+ 2. Navigate to /operations.
+ 3. Count the elements in the (excluding the placeholder).
+ 4. Assert equality.
+ """
+ operations = get_operations_from_api(api_session, caldera_server)
+ expected_count = len(operations)
+
+ navigate_to_operations(auth_page, base_url)
+
+ # OperationsView uses a native ; each operation is an .
+ # Exclude the placeholder option with value="".
+ select_el = auth_page.locator(".columns .column.is-4 select")
+ operation_options = select_el.first.locator('option:not([value=""])')
+ actual_count = operation_options.count()
+
+ assert actual_count == expected_count, (
+ f"Selector shows {actual_count} operation(s) but API returned {expected_count}"
+ )
+
+
+def test_operation_names_in_dropdown(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ When at least one operation exists, the first operation's name returned by
+ GET /api/v2/operations must appear as a selectable option in the .
+
+ OperationsView renders operation options as:
+ "name - N decisions | date"
+ so we check that an option containing the operation name is present.
+
+ If no operations exist the test is skipped.
+ """
+ operations = get_operations_from_api(api_session, caldera_server)
+ if not operations:
+ pytest.skip("No operations registered — cannot verify selector names.")
+
+ first_name = operations[0]["name"]
+
+ navigate_to_operations(auth_page, base_url)
+
+ # The operation's name should appear as an option in the
+ select_el = auth_page.locator(".columns .column.is-4 select")
+ option = select_el.first.locator("option", has_text=first_name)
+ expect(option.first).to_be_attached()
+
+
+def test_delete_button_hidden_when_no_operation_selected(
+ auth_page: Page, base_url: str
+) -> None:
+ """
+ When no operation is selected, the "Delete" button must not be visible.
+
+ In the template the Delete button uses `v-if="selectedOperation.id"` so it
+ should be absent from the DOM (or hidden) when the selection is empty.
+ """
+ navigate_to_operations(auth_page, base_url)
+
+ # The Delete button must not be visible when nothing is selected.
+ # Using to_not_be_visible covers both the v-if removal from DOM and a
+ # CSS display:none scenario.
+ delete_btn = auth_page.get_by_role("button", name="Delete")
+ expect(delete_btn).not_to_be_visible()
+
+
+def test_download_report_hidden_when_no_operation_selected(
+ auth_page: Page, base_url: str
+) -> None:
+ """
+ When no operation is selected, the "Download Report" button must not be
+ visible.
+
+ In the template the button uses `v-if="selectedOperation.id"` so it
+ should be absent from the DOM (or hidden) when the selection is empty.
+ """
+ navigate_to_operations(auth_page, base_url)
+
+ # The Download Report button must not be visible when nothing is selected.
+ download_btn = auth_page.get_by_role("button", name="Download Report")
+ expect(download_btn).not_to_be_visible()
diff --git a/tests/e2e/test_payloads.py b/tests/e2e/test_payloads.py
new file mode 100644
index 0000000..bd53421
--- /dev/null
+++ b/tests/e2e/test_payloads.py
@@ -0,0 +1,227 @@
+"""
+E2E tests for the Caldera Payloads page (PayloadsView.vue).
+
+Covers page structure (heading, description, file upload control), list/API
+count parity, payload name rendering, and an upload-then-verify lifecycle
+that cleans up after itself.
+
+All tests use the `auth_page` fixture so auth cookies are present before
+navigation. Tests that upload data delete the created payload via
+`api_session` so the suite remains idempotent.
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_payloads.py -v --browser chromium
+"""
+
+import io
+
+import pytest
+from playwright.sync_api import expect, Page
+
+
+# ---------------------------------------------------------------------------
+# Helper
+# ---------------------------------------------------------------------------
+
+_TEST_PAYLOAD_NAME = 'test_e2e_payload.txt'
+_TEST_PAYLOAD_CONTENT = b'test'
+
+
+# ---------------------------------------------------------------------------
+# 1. h2 heading "Payloads" is visible
+# ---------------------------------------------------------------------------
+
+def test_payloads_page_heading(auth_page: Page, base_url: str) -> None:
+ """
+ Navigating to /payloads must render an with the text "Payloads".
+ The PayloadsView also renders "Local Payloads" and "Plugin Payloads" h2
+ elements; we check for the top-level "Payloads" heading specifically.
+ """
+ auth_page.goto(base_url + '/payloads')
+ auth_page.wait_for_load_state('networkidle')
+
+ # Use get_by_role with exact match to find the exact "Payloads" heading
+ heading = auth_page.get_by_role('heading', name='Payloads', exact=True)
+ expect(heading).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Introductory description paragraph is visible
+# ---------------------------------------------------------------------------
+
+def test_payloads_description_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The page must display a descriptive paragraph below the heading that
+ explains the purpose of payloads. The PayloadsView template renders:
+ "Payloads are any files that you can reference in ability executors."
+ """
+ auth_page.goto(base_url + '/payloads')
+ auth_page.wait_for_load_state('networkidle')
+
+ # The paragraph text contains "ability executors" (not "abilities")
+ description = auth_page.locator('p', has_text='ability executors')
+ expect(description).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 3. Payload list item count matches the API count
+# ---------------------------------------------------------------------------
+
+def test_payloads_api_count_matches_ui(
+ auth_page: Page, base_url: str, api_session
+) -> None:
+ """
+ The number of payload rows rendered in the UI must equal the number of
+ payload name strings returned by GET /api/v2/payloads.
+
+ When the API returns zero payloads the test passes trivially.
+ """
+ resp = api_session.get(base_url + '/api/v2/payloads')
+ assert resp.status_code == 200, (
+ f'GET /api/v2/payloads returned HTTP {resp.status_code}'
+ )
+ api_payloads = resp.json()
+ api_count = len(api_payloads)
+
+ auth_page.goto(base_url + '/payloads')
+ auth_page.wait_for_load_state('networkidle')
+
+ # PayloadsView renders two tables: "Local Payloads" and "Plugin Payloads".
+ # Each payload is a inside a . We count all tbody rows combined.
+ tbody_rows = auth_page.locator('tbody tr')
+
+ if api_count == 0:
+ # The API reports no payloads — but the regex filter in the Vue component
+ # may discard payloads that don't match the expected path format.
+ # Accept zero rows as confirmation of empty state.
+ assert tbody_rows.count() == 0, (
+ f'API returned 0 payloads but {tbody_rows.count()} rows are visible'
+ )
+ return
+
+ # Wait for Vue to render at least one row
+ expect(tbody_rows.first).to_be_visible()
+ ui_count = tbody_rows.count()
+
+ assert ui_count == api_count, (
+ f'UI shows {ui_count} payload rows but API returned {api_count} payloads'
+ )
+
+
+# ---------------------------------------------------------------------------
+# 4. First payload name from the API appears on the page
+# ---------------------------------------------------------------------------
+
+def test_payload_names_displayed(
+ auth_page: Page, base_url: str, api_session
+) -> None:
+ """
+ After navigating to /payloads, the filename of the first payload returned
+ by GET /api/v2/payloads must be visible somewhere on the page, confirming
+ that the Vue component has rendered data fetched from the API.
+
+ Skipped when no payloads are present in the system.
+ """
+ resp = api_session.get(base_url + '/api/v2/payloads')
+ assert resp.status_code == 200, (
+ f'GET /api/v2/payloads returned HTTP {resp.status_code}'
+ )
+ payloads = resp.json()
+
+ if not payloads:
+ pytest.skip('No payloads available to verify against the UI')
+
+ first_name = payloads[0]
+
+ auth_page.goto(base_url + '/payloads')
+ auth_page.wait_for_load_state('networkidle')
+
+ name_locator = auth_page.locator(f'text={first_name}')
+ expect(name_locator.first).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 5. File upload input (or upload dropzone / button) is present on the page
+# ---------------------------------------------------------------------------
+
+def test_upload_input_present(auth_page: Page, base_url: str) -> None:
+ """
+ The Payloads page must provide a mechanism for uploading files. This is
+ expected to be either an ` ` element, a visible button
+ labelled "Upload", or a dropzone area — at least one of these must be
+ present and attached to the DOM.
+ """
+ auth_page.goto(base_url + '/payloads')
+ auth_page.wait_for_load_state('networkidle')
+
+ # Primary selector: a standard file input
+ file_input = auth_page.locator('input[type="file"]')
+ upload_button = auth_page.locator('button', has_text='Upload')
+ dropzone = auth_page.locator('[class*="dropzone"], [class*="drop-zone"], [class*="upload"]')
+
+ # At least one upload mechanism must be present in the DOM
+ has_file_input = file_input.count() > 0
+ has_upload_button = upload_button.count() > 0
+ has_dropzone = dropzone.count() > 0
+
+ assert has_file_input or has_upload_button or has_dropzone, (
+ 'No file upload control found on /payloads: '
+ 'expected input[type="file"], an "Upload" button, or a dropzone element'
+ )
+
+
+# ---------------------------------------------------------------------------
+# 6. A payload uploaded via the API appears in the UI; cleaned up afterwards
+# ---------------------------------------------------------------------------
+
+def test_payload_upload_via_api_appears_in_ui(
+ auth_page: Page, base_url: str, api_session
+) -> None:
+ """
+ A small text payload POSTed directly to POST /api/v2/payloads must appear
+ on the /payloads page after navigation, confirming that the Vue component
+ renders the full server-side payload list.
+
+ The test payload is deleted via DELETE /api/v2/payloads/{name} after the
+ assertion so no state is left behind.
+ """
+ # --- Ensure no leftover test payload from a previous run ---
+ pre_delete = api_session.delete(base_url + f'/api/v2/payloads/{_TEST_PAYLOAD_NAME}')
+ # 404 is acceptable here (payload didn't exist); anything else is unexpected
+ assert pre_delete.status_code in (200, 204, 404), (
+ f'Pre-test cleanup DELETE returned HTTP {pre_delete.status_code}'
+ )
+
+ # --- Upload the test payload via the API ---
+ upload_resp = api_session.post(
+ base_url + '/api/v2/payloads',
+ files={
+ 'file': (
+ _TEST_PAYLOAD_NAME,
+ io.BytesIO(_TEST_PAYLOAD_CONTENT),
+ 'text/plain',
+ )
+ },
+ )
+ assert upload_resp.status_code in (200, 201), (
+ f'POST /api/v2/payloads returned HTTP {upload_resp.status_code}: '
+ f'{upload_resp.text}'
+ )
+
+ try:
+ # --- Navigate to /payloads and verify the uploaded file is listed ---
+ auth_page.goto(base_url + '/payloads')
+ auth_page.wait_for_load_state('networkidle')
+
+ payload_text = auth_page.locator(f'text={_TEST_PAYLOAD_NAME}')
+ expect(payload_text.first).to_be_visible()
+
+ finally:
+ # --- Clean up: delete the test payload regardless of assertion outcome ---
+ del_resp = api_session.delete(
+ base_url + f'/api/v2/payloads/{_TEST_PAYLOAD_NAME}'
+ )
+ assert del_resp.status_code in (200, 204), (
+ f'Cleanup DELETE /api/v2/payloads/{_TEST_PAYLOAD_NAME} returned '
+ f'HTTP {del_resp.status_code}'
+ )
diff --git a/tests/e2e/test_planners.py b/tests/e2e/test_planners.py
new file mode 100644
index 0000000..94182fe
--- /dev/null
+++ b/tests/e2e/test_planners.py
@@ -0,0 +1,179 @@
+"""
+E2E tests for the Caldera Planners UI view (/planners).
+
+Planners are built-in modules that decide which abilities a red team agent
+should execute during an operation. The view is read-only — users cannot
+create or delete planners through the UI.
+
+Fixtures used (provided by conftest.py):
+ caldera_server (session) — base URL string
+ api_session (session) — authenticated requests.Session
+ auth_page (function) — Playwright page with auth cookies, not yet navigated
+ base_url (function) — base URL string
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_planners.py -v --browser chromium
+"""
+
+import pytest
+from playwright.sync_api import expect, Page
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def navigate_to_planners(page: Page, base_url: str) -> None:
+ """Navigate to the /planners route and wait for network activity to settle."""
+ page.goto(f"{base_url}/planners")
+ page.wait_for_load_state("networkidle")
+
+
+def get_planners_from_api(api_session, base_url: str) -> list:
+ """Return the list of planner objects from the REST API."""
+ resp = api_session.get(f"{base_url}/api/v2/planners")
+ assert resp.status_code == 200, (
+ f"GET /api/v2/planners returned HTTP {resp.status_code}"
+ )
+ return resp.json()
+
+
+# ---------------------------------------------------------------------------
+# 1. h2 heading "Planners" is visible
+# ---------------------------------------------------------------------------
+
+def test_planners_page_heading(auth_page: Page, base_url: str) -> None:
+ """
+ Navigating to /planners must render an whose text is "Planners".
+ This confirms the correct Vue view is mounted by the router.
+ """
+ navigate_to_planners(auth_page, base_url)
+
+ heading = auth_page.locator("h2", has_text="Planners")
+ expect(heading).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Introductory description paragraph is visible
+# ---------------------------------------------------------------------------
+
+def test_planners_description_visible(auth_page: Page, base_url: str) -> None:
+ """
+ Below the heading there must be a descriptive paragraph explaining what
+ a planner is. The text matches the static copy in the Pug template
+ (begins with "A planner is a module").
+ """
+ navigate_to_planners(auth_page, base_url)
+
+ # The Pug template renders "A planner is a module that decides which
+ # abilities a red team agent should execute..."
+ description = auth_page.locator("p", has_text="A planner is a module")
+ expect(description).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 3. GET /api/v2/planners returns a non-empty list
+# ---------------------------------------------------------------------------
+
+def test_planners_api_returns_entries(api_session, base_url: str) -> None:
+ """
+ The planners REST endpoint must return a non-empty JSON array.
+ Caldera ships with at least one built-in planner (e.g. "sequential"),
+ so an empty response indicates a configuration problem.
+ """
+ planners = get_planners_from_api(api_session, base_url)
+ assert len(planners) > 0, (
+ "GET /api/v2/planners returned an empty list — "
+ "at least one built-in planner is expected."
+ )
+
+
+# ---------------------------------------------------------------------------
+# 4. Planner item count in the UI matches the API count
+# ---------------------------------------------------------------------------
+
+def test_planners_count_matches_ui(
+ auth_page: Page, base_url: str, api_session
+) -> None:
+ """
+ The number of planner cards (or list entries) rendered on the page must
+ equal the number of objects returned by GET /api/v2/planners.
+
+ Planners are rendered via a v-for loop; each item is expected to be a
+ card (.card) or a list item ( ) inside the main content area. We
+ try the most specific selector first and fall back gracefully.
+ """
+ planners = get_planners_from_api(api_session, base_url)
+ api_count = len(planners)
+
+ navigate_to_planners(auth_page, base_url)
+
+ if api_count == 0:
+ # No planners in the system — nothing should be rendered.
+ # This path is unexpected for a standard Caldera install.
+ cards = auth_page.locator(".card")
+ assert cards.count() == 0, (
+ f"API returned 0 planners but {cards.count()} cards are visible"
+ )
+ return
+
+ # Wait for Vue to finish rendering at least one planner entry.
+ # Planners are typically rendered as .card elements in a v-for loop.
+ cards = auth_page.locator(".card")
+ expect(cards.first).to_be_visible()
+ ui_count = cards.count()
+
+ assert ui_count == api_count, (
+ f"UI shows {ui_count} planner card(s) but API returned {api_count}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 5. Each planner's name from the API appears on the page
+# ---------------------------------------------------------------------------
+
+def test_planner_names_displayed(
+ auth_page: Page, base_url: str, api_session
+) -> None:
+ """
+ After navigating to /planners, every planner name returned by
+ GET /api/v2/planners must appear somewhere on the page, confirming
+ the Vue component renders all API data correctly.
+
+ Skipped when the API returns no planners (unexpected for a live server).
+ """
+ planners = get_planners_from_api(api_session, base_url)
+
+ if not planners:
+ pytest.skip("No planners available — cannot verify name rendering.")
+
+ navigate_to_planners(auth_page, base_url)
+
+ for planner in planners:
+ name = planner.get("name", "")
+ assert name, f"Planner entry is missing a 'name' field: {planner}"
+
+ name_locator = auth_page.locator(f"text={name}")
+ expect(name_locator.first).to_be_visible(
+ timeout=5000
+ ), f"Planner name '{name}' was not found on the /planners page."
+
+
+# ---------------------------------------------------------------------------
+# 6. No "Create" or "New" button is present (planners are read-only)
+# ---------------------------------------------------------------------------
+
+def test_planners_are_read_only(auth_page: Page, base_url: str) -> None:
+ """
+ Planners are built-in modules and cannot be created through the UI.
+ Neither a "Create" button nor a "New" button must be present on the page.
+ """
+ navigate_to_planners(auth_page, base_url)
+
+ # Assert that no button with "Create" or "New" text exists in the DOM.
+ # `to_be_hidden()` passes when the element is absent or not visible.
+ create_btn = auth_page.locator("button", has_text="Create")
+ expect(create_btn).to_be_hidden()
+
+ new_btn = auth_page.locator("button", has_text="New")
+ expect(new_btn).to_be_hidden()
diff --git a/tests/e2e/test_schedules.py b/tests/e2e/test_schedules.py
new file mode 100644
index 0000000..b95a9f6
--- /dev/null
+++ b/tests/e2e/test_schedules.py
@@ -0,0 +1,224 @@
+"""
+Playwright E2E tests for the Caldera Schedules UI view (/schedules).
+
+These tests verify the page structure, visibility of key elements, modal
+interaction, and API/UI consistency for the SchedulesView component.
+
+Fixtures used (provided by conftest.py):
+ caldera_server (session) — base URL string
+ api_session (session) — authenticated requests.Session
+ auth_page (function) — Playwright page with auth cookies, not yet navigated
+ base_url (function) — base URL string
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_schedules.py -v --browser chromium
+"""
+
+import pytest
+from playwright.sync_api import expect, Page
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def navigate_to_schedules(page: Page, base_url: str) -> None:
+ """Navigate to the /schedules route and wait for network activity to settle."""
+ page.goto(f"{base_url}/schedules")
+ page.wait_for_load_state("networkidle")
+
+
+def get_schedules_from_api(api_session, caldera_server: str) -> list:
+ """Return the list of schedule objects from the REST API."""
+ resp = api_session.get(f"{caldera_server}/api/v2/schedules")
+ assert resp.status_code == 200, (
+ f"GET /api/v2/schedules returned HTTP {resp.status_code}"
+ )
+ return resp.json()
+
+
+# ---------------------------------------------------------------------------
+# 1. h2 heading "Schedules" is visible
+# ---------------------------------------------------------------------------
+
+def test_schedules_page_heading(auth_page: Page, base_url: str) -> None:
+ """
+ Navigating to /schedules must render an element whose text is
+ "Schedules", confirming the correct view has been routed to.
+ """
+ navigate_to_schedules(auth_page, base_url)
+
+ heading = auth_page.locator("h2", has_text="Schedules")
+ expect(heading).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Introductory description paragraph is visible
+# ---------------------------------------------------------------------------
+
+def test_schedules_description_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The SchedulesView does not have a static description paragraph.
+ It renders an h2 heading, an hr divider, a button row with "Create a schedule",
+ and a table of schedules. We verify the page loaded correctly by checking
+ the h2 heading and the "Create a schedule" button are both visible.
+ """
+ navigate_to_schedules(auth_page, base_url)
+
+ # The h2 heading must be visible
+ heading = auth_page.locator("h2", has_text="Schedules")
+ expect(heading).to_be_visible()
+
+ # The "Create a schedule" primary button must also be visible
+ create_btn = auth_page.locator("button", has_text="Create a schedule")
+ expect(create_btn).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 3. "Create a schedule" primary button is visible
+# ---------------------------------------------------------------------------
+
+def test_create_schedule_button_visible(auth_page: Page, base_url: str) -> None:
+ """
+ The primary call-to-action button labelled "Create a schedule" must be
+ present and visible in the button toolbar area.
+ """
+ navigate_to_schedules(auth_page, base_url)
+
+ create_btn = auth_page.locator("button", has_text="Create a schedule")
+ expect(create_btn).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 4. Clicking "Create a schedule" opens a modal with form fields
+# ---------------------------------------------------------------------------
+
+def test_create_schedule_modal_opens(auth_page: Page, base_url: str) -> None:
+ """
+ Clicking the "Create a schedule" button must open a modal dialog that
+ contains at least one input field, indicating the create-schedule form
+ is active and ready to receive user input.
+ """
+ navigate_to_schedules(auth_page, base_url)
+
+ create_btn = auth_page.locator("button", has_text="Create a schedule")
+ expect(create_btn).to_be_visible()
+ create_btn.click()
+
+ # A Bulma modal becomes active by gaining the "is-active" class.
+ # Wait for the transition before asserting.
+ modal = auth_page.locator(".modal.is-active")
+ expect(modal).to_be_visible()
+
+ # The modal must contain at least one input or select element (the form).
+ form_field = modal.locator("input, select, textarea").first
+ expect(form_field).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 5. UI schedule item count matches API response count
+# ---------------------------------------------------------------------------
+
+def test_schedules_api_count_matches_ui(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ The number of schedule entries rendered in the schedule list must equal
+ the number of schedule objects returned by GET /api/v2/schedules.
+
+ When the API returns zero schedules the test verifies that no list items
+ are present; when schedules exist, the DOM row count must match.
+ """
+ schedules = get_schedules_from_api(api_session, caldera_server)
+ api_count = len(schedules)
+
+ navigate_to_schedules(auth_page, base_url)
+
+ if api_count == 0:
+ # With no schedules the list should be empty — verify no schedule rows.
+ # Schedule entries are expected to be rendered as list items or table
+ # rows in a repeating block; accept zero as confirmation of empty state.
+ rows = auth_page.locator(".schedule-item, tbody tr, [data-schedule]")
+ assert rows.count() == 0, (
+ f"API returned 0 schedules but {rows.count()} items are visible in the UI"
+ )
+ return
+
+ # When schedules exist wait for at least one row to appear.
+ # Schedule entries are rendered inside a repeating list; rows may be
+ # elements inside a table or generic container items depending on template.
+ # Try table rows first, then fall back to a broader list-item selector.
+ rows = auth_page.locator("tbody tr")
+ if rows.count() == 0:
+ rows = auth_page.locator(".schedule-item, [data-schedule]")
+
+ expect(rows.first).to_be_visible()
+ ui_count = rows.count()
+
+ assert ui_count == api_count, (
+ f"UI shows {ui_count} schedule row(s) but API returned {api_count}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 6. First schedule's name from the API appears on the page
+# ---------------------------------------------------------------------------
+
+def test_schedule_names_displayed(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ When at least one schedule exists, the name of the first schedule returned
+ by GET /api/v2/schedules must be visible somewhere on the page, confirming
+ that the Vue component has rendered data fetched from the API.
+
+ Skipped when no schedules are present in the system.
+ """
+ schedules = get_schedules_from_api(api_session, caldera_server)
+
+ if not schedules:
+ pytest.skip("No schedules present — cannot verify name display.")
+
+ first_name = schedules[0].get("name", "")
+ if not first_name:
+ pytest.skip("First schedule has no name field — skipping.")
+
+ navigate_to_schedules(auth_page, base_url)
+
+ name_locator = auth_page.locator(f"text={first_name}")
+ expect(name_locator.first).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 7. Schedule cron expression is visible on the page
+# ---------------------------------------------------------------------------
+
+def test_schedule_cron_expression_displayed(
+ auth_page: Page, base_url: str, api_session, caldera_server: str
+) -> None:
+ """
+ When at least one schedule has a non-empty 'schedule' (cron) field, that
+ cron expression string must appear somewhere visible on the /schedules page.
+
+ This confirms the template renders the schedule expression alongside the
+ schedule name in the list.
+
+ Skipped when no schedules with a cron expression are present.
+ """
+ schedules = get_schedules_from_api(api_session, caldera_server)
+
+ # Find the first schedule that has a non-empty schedule/cron field.
+ target_cron: str | None = None
+ for sched in schedules:
+ cron = sched.get("schedule", "")
+ if cron:
+ target_cron = cron
+ break
+
+ if target_cron is None:
+ pytest.skip("No schedules with a cron expression found — skipping.")
+
+ navigate_to_schedules(auth_page, base_url)
+
+ cron_locator = auth_page.locator(f"text={target_cron}")
+ expect(cron_locator.first).to_be_visible()
diff --git a/tests/e2e/test_settings.py b/tests/e2e/test_settings.py
new file mode 100644
index 0000000..e23ee23
--- /dev/null
+++ b/tests/e2e/test_settings.py
@@ -0,0 +1,128 @@
+"""
+E2E tests for the Caldera Settings page (SettingsView.vue).
+
+Settings displays Caldera's current main configuration and allows some
+values to be updated via a code editor. It also lists active plugins.
+
+Run with:
+ pytest plugins/magma/tests/e2e/test_settings.py -v --browser chromium
+"""
+
+from playwright.sync_api import Page, expect
+
+
+def test_settings_page_loads(auth_page: Page, base_url: str) -> None:
+ """Navigating to /settings stays on /settings (no redirect to /login)."""
+ auth_page.goto(base_url + '/settings')
+ auth_page.wait_for_load_state('networkidle')
+ assert '/login' not in auth_page.url, (
+ f'Expected to stay on /settings but ended up at {auth_page.url}'
+ )
+
+
+def test_settings_config_api_returns_data(api_session, base_url: str) -> None:
+ """
+ GET /api/v2/config/main returns a non-empty dict.
+
+ Sensitive keys (port, host, api_key_red, api_key_blue, crypt_salt,
+ encryption_key, users, requirements) are stripped by the security filter,
+ so we verify the response is a dict with at least one key rather than
+ checking for any specific sensitive key.
+ """
+ resp = api_session.get(f'{base_url}/api/v2/config/main')
+ assert resp.status_code == 200
+ config = resp.json()
+ assert isinstance(config, dict), f"Expected dict, got {type(config)}"
+ assert len(config) > 0, "Config response is unexpectedly empty"
+ # Verify that sensitive keys have been stripped
+ sensitive_keys = {'port', 'host', 'api_key_red', 'api_key_blue',
+ 'crypt_salt', 'encryption_key', 'users', 'requirements'}
+ exposed_sensitive = sensitive_keys & set(config.keys())
+ assert not exposed_sensitive, (
+ f"Sensitive config keys are exposed in the API response: {exposed_sensitive}"
+ )
+
+
+def test_settings_displays_config_value(auth_page: Page, api_session, base_url: str) -> None:
+ """
+ A non-sensitive config value from the API appears somewhere on the settings page.
+
+ The SettingsView renders all non-sensitive config keys as editable input fields.
+ We pick the first key from the filtered config (which excludes sensitive keys
+ like port, host, api_key_red, etc.) and verify its value is visible on the page.
+ """
+ resp = api_session.get(f'{base_url}/api/v2/config/main')
+ assert resp.status_code == 200
+ config = resp.json()
+
+ # Find a non-sensitive key whose value is a non-empty string we can search for
+ target_key = None
+ target_value = None
+ for key, value in config.items():
+ if isinstance(value, str) and value.strip():
+ target_key = key
+ target_value = value.strip()
+ break
+
+ if not target_key:
+ import pytest
+ pytest.skip("No non-sensitive string config values available to check")
+
+ auth_page.goto(base_url + '/settings')
+ auth_page.wait_for_load_state('networkidle')
+ # The settings page renders each config key as a label and its value in an input
+ expect(auth_page.get_by_text(target_key, exact=False)).to_be_visible()
+
+
+def test_settings_plugins_api_returns_list(api_session, base_url: str) -> None:
+ """GET /api/v2/plugins returns a non-empty list of plugin objects."""
+ resp = api_session.get(f'{base_url}/api/v2/plugins')
+ assert resp.status_code == 200
+ plugins = resp.json()
+ assert isinstance(plugins, list)
+ assert len(plugins) > 0, "Expected at least one plugin to be registered"
+
+
+def test_settings_page_has_input_fields(auth_page: Page, base_url: str) -> None:
+ """
+ The settings page renders config values as editable fields.
+
+ SettingsView renders each non-sensitive config key as a table row with a
+ plain element (not a code editor). We verify that at least
+ one input field is visible on the settings page.
+ """
+ auth_page.goto(base_url + '/settings')
+ auth_page.wait_for_load_state('networkidle')
+ # SettingsView renders each setting as an input.input element in a table row
+ input_field = auth_page.locator('input.input').first
+ expect(input_field).to_be_visible()
+
+
+def test_settings_api_key_not_in_page_source(auth_page: Page, api_session, base_url: str) -> None:
+ """
+ The raw api_key_red value from the config API should NOT appear verbatim
+ in the rendered page — it must be masked or omitted in the UI.
+ """
+ config_resp = api_session.get(f'{base_url}/api/v2/config/main')
+ api_key = config_resp.json().get('api_key_red', '')
+
+ if not api_key or api_key.startswith('$argon2'):
+ # Hashed key — already opaque, skip this check
+ return
+
+ auth_page.goto(base_url + '/settings')
+ auth_page.wait_for_load_state('networkidle')
+ page_text = auth_page.content()
+ assert api_key not in page_text, (
+ 'Raw api_key_red value is visible in the settings page source — '
+ 'it should be masked or omitted for security.'
+ )
+
+
+def test_settings_navigation_links_visible(auth_page: Page, base_url: str) -> None:
+ """Navigation links to other sections are present on the settings page."""
+ auth_page.goto(base_url + '/settings')
+ auth_page.wait_for_load_state('networkidle')
+ # The sidebar navigation should link to core views
+ for label in ('Agents', 'Operations', 'Abilities'):
+ expect(auth_page.get_by_role('link', name=label, exact=False)).to_be_visible()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..e57ac44
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,21 @@
+[tox]
+skipsdist = True
+
+[testenv:ui]
+description = Run Playwright E2E UI tests (requires Caldera installed with magma as plugin)
+deps =
+ -r{toxinidir}/requirements-ui-tests.txt
+ pytest
+ pytest-asyncio==0.26.0
+ pyyaml
+allowlist_externals =
+ bash
+ nodeenv
+ npm
+ playwright
+skip_install = true
+commands =
+ nodeenv --node=20.11.0 --python-virtualenv --prebuilt --quiet
+ bash -c "npm ci --prefer-offline && npm run build"
+ playwright install chromium
+ pytest tests/e2e --tb=short -v {posargs}