Unclosed span
- Some text
- """
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1,
- id: "formatted-content",
- content: content
- )
-
- parsed_html = Floki.parse_document!(html)
-
- assert Floki.text(parsed_html) =~ "Unclosed div"
- assert Floki.text(parsed_html) =~ "Unclosed span"
- assert Floki.text(parsed_html) =~ "Some text"
- end
-
- test "applies styles to elements not defined in the default styles" do
- content = """
- Custom styled content
- """
-
- custom_attributes = %{
- "custom-tag" => %{class: "custom-class text-green-700"}
- }
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1, %{
- id: "formatted-content",
- content: content,
- attributes: custom_attributes
- })
-
- parsed_html = Floki.parse_document!(html)
- custom_tag = Floki.find(parsed_html, "custom-tag") |> hd()
-
- assert custom_tag != nil
-
- assert Floki.attribute(custom_tag, "class") == [
- "custom-class text-green-700"
- ]
- end
-
- test "renders code blocks with language class" do
- content = """
- Here's some code:
-
- ```javascript
- console.log("hello");
- ```
-
- And more text.
- """
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1,
- id: "formatted-content",
- content: content
- )
-
- parsed_html = Floki.parse_document!(html)
-
- # Find the code element inside pre
- code_elements = Floki.find(parsed_html, "code")
- assert length(code_elements) > 0
-
- # MDEx emits the standard `language-` prefix on fenced code blocks
- code_element = hd(code_elements)
- assert Floki.attribute(code_element, "class") == ["language-javascript"]
- end
-
- test "applies styling classes to standard markdown elements" do
- content = """
- # Title
-
- ## Subtitle
-
- Some paragraph text.
-
- - bullet one
- - bullet two
-
- 1. step one
- 2. step two
- """
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1,
- id: "formatted-content",
- content: content
- )
-
- parsed_html = Floki.parse_document!(html)
-
- assert Floki.attribute(Floki.find(parsed_html, "h1"), "class") == [
- "text-2xl font-bold mb-6"
- ]
-
- assert Floki.attribute(Floki.find(parsed_html, "h2"), "class") == [
- "text-xl font-semibold mb-4 mt-8"
- ]
-
- assert Floki.attribute(Floki.find(parsed_html, "ul"), "class") == [
- "list-disc pl-8 space-y-1"
- ]
-
- assert Floki.attribute(Floki.find(parsed_html, "ol"), "class") == [
- "list-decimal pl-8 space-y-1"
- ]
-
- assert "mt-1 mb-2 text-gray-800" in Floki.attribute(
- Floki.find(parsed_html, "p"),
- "class"
- )
-
- assert Enum.all?(
- Floki.find(parsed_html, "li"),
- &(Floki.attribute(&1, "class") == ["text-gray-800"])
- )
- end
-
- test "renders GFM extensions (tables, strikethrough, autolinks)" do
- content = """
- | Name | Role |
- |------|------|
- | Ada | Dev |
-
- ~~deprecated~~ and see https://example.com for details.
- """
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1,
- id: "formatted-content",
- content: content
- )
-
- parsed_html = Floki.parse_document!(html)
-
- # GFM tables render as a real table, not raw pipe text
- assert Floki.find(parsed_html, "table") != []
- assert Floki.find(parsed_html, "th") |> Floki.text() =~ "Name"
- assert Floki.find(parsed_html, "td") |> Floki.text() =~ "Ada"
-
- # Strikethrough
- assert Floki.find(parsed_html, "del") |> Floki.text() == "deprecated"
-
- # Bare URL is autolinked
- assert Enum.any?(
- Floki.find(parsed_html, "a"),
- &(Floki.attribute(&1, "href") == ["https://example.com"])
- )
- end
- end
-
- describe "error_message/1" do
- test "renders string error message" do
- assert JobCode.error_message({:error, "Something went wrong"}) ==
- "Something went wrong"
- end
-
- test "renders changeset error message" do
- changeset = %Ecto.Changeset{
- valid?: false,
- errors: [content: {"is invalid", []}],
- data: %Lightning.AiAssistant.ChatSession{}
- }
-
- assert JobCode.error_message({:error, changeset}) ==
- "Content is invalid"
- end
-
- test "renders text message from map" do
- error_data = %{text: "Specific error message"}
-
- assert JobCode.error_message({:error, :custom_reason, error_data}) ==
- "Specific error message"
- end
-
- test "renders default error message for unhandled cases" do
- assert JobCode.error_message({:error, :unknown_reason}) ==
- "An error occurred: unknown_reason. Please try again."
-
- assert JobCode.error_message(:unexpected_error) ==
- "Oops! Something went wrong. Please try again."
- end
-
- test "elements without defined styles remain unchanged" do
- content = """
- Some code
- Preformatted text
- [A link](https://weirdopierdo.com)
- """
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1,
- id: "formatted-content",
- content: content
- )
-
- parsed_html = Floki.parse_document!(html)
-
- code = Floki.find(parsed_html, "weirdo")
- pre = Floki.find(parsed_html, "pierdo")
-
- assert Floki.attribute(code, "class") == []
- assert Floki.attribute(pre, "class") == []
-
- link =
- Floki.find(parsed_html, "a")
- |> Enum.find(
- &(Floki.attribute(&1, "href") == ["https://weirdopierdo.com"])
- )
-
- assert link != nil
-
- assert Floki.attribute(link, "class") == [
- "text-primary-400 hover:text-primary-600"
- ]
-
- assert Floki.attribute(link, "target") == ["_blank"]
- end
-
- test "handles content that cannot be parsed as AST" do
- content = """
- Unclosed div
- Unclosed span
- Some text
- """
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1,
- id: "formatted-content",
- content: content
- )
-
- parsed_html = Floki.parse_document!(html)
-
- text = Floki.text(parsed_html)
- assert text =~ "Unclosed div"
- assert text =~ "Unclosed span"
- assert text =~ "Some text"
- end
-
- test "applies styles to elements not defined in the default styles" do
- content = """
- Custom styled content
- """
-
- custom_attributes = %{
- "custom-tag" => %{class: "custom-class text-green-700"}
- }
-
- html =
- render_component(&AiAssistant.Component.formatted_content/1, %{
- id: "formatted-content",
- content: content,
- attributes: custom_attributes
- })
-
- parsed_html = Floki.parse_document!(html)
-
- custom_tag = Floki.find(parsed_html, "custom-tag") |> hd()
-
- assert custom_tag != nil
-
- assert Floki.attribute(custom_tag, "class") == [
- "custom-class text-green-700"
- ]
- end
- end
-
- describe "form validation" do
- alias LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate
-
- test "JobCode Form validates empty content" do
- changeset = JobCode.Form.changeset(%{"content" => ""})
-
- assert changeset.valid? == false
- assert Keyword.has_key?(changeset.errors, :content)
- {msg, _opts} = changeset.errors[:content]
- assert msg == "Please enter a message before sending"
- end
-
- test "JobCode validate_form includes content validation" do
- changeset = JobCode.validate_form(%{"content" => nil})
-
- assert changeset.valid? == false
- assert Keyword.has_key?(changeset.errors, :content)
- end
-
- test "WorkflowTemplate DefaultForm validates empty content" do
- changeset = WorkflowTemplate.DefaultForm.changeset(%{"content" => ""})
-
- assert changeset.valid? == false
- assert Keyword.has_key?(changeset.errors, :content)
- {msg, _opts} = changeset.errors[:content]
- assert msg == "Please enter a message before sending"
- end
-
- test "form validation accepts valid content" do
- # JobCode
- changeset = JobCode.validate_form(%{"content" => "Help me with my code"})
- assert changeset.valid? == true
-
- # WorkflowTemplate
- changeset =
- WorkflowTemplate.validate_form(%{"content" => "Create a workflow"})
-
- assert changeset.valid? == true
- end
- end
-end
diff --git a/test/lightning_web/live/workflow_live/collaborate_test.exs b/test/lightning_web/live/workflow_live/collaborate_test.exs
index a98816facbe..d3df4a085e2 100644
--- a/test/lightning_web/live/workflow_live/collaborate_test.exs
+++ b/test/lightning_web/live/workflow_live/collaborate_test.exs
@@ -2558,169 +2558,4 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do
refute html =~ "data-initial-run-data="
end
end
-
- describe "legacy editor preference redirect" do
- test "redirects to legacy editor when user prefers legacy editor", %{
- conn: conn
- } do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Test Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
-
- # Set user preference to prefer legacy editor
- user_with_prefs =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{
- "prefer_legacy_editor" => true
- }
- })
- |> Lightning.Repo.update!()
-
- # Try to navigate to collaborative editor
- {:error, {:live_redirect, %{to: redirect_path}}} =
- conn
- |> log_in_user(user_with_prefs)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}")
-
- # Should redirect to legacy editor
- assert redirect_path == "/projects/#{project.id}/w/#{workflow.id}/legacy"
- end
-
- test "redirects to legacy editor for new workflow when user prefers legacy editor",
- %{conn: conn} do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Test Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- # Set user preference to prefer legacy editor
- user_with_prefs =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{
- "prefer_legacy_editor" => true
- }
- })
- |> Lightning.Repo.update!()
-
- # Try to navigate to collaborative editor for new workflow
- {:error, {:live_redirect, %{to: redirect_path}}} =
- conn
- |> log_in_user(user_with_prefs)
- |> live(~p"/projects/#{project.id}/w/new")
-
- # Should redirect to legacy editor with method=template
- assert redirect_path ==
- "/projects/#{project.id}/w/new/legacy?method=template"
- end
-
- test "redirects to legacy editor with query params transformed", %{
- conn: conn
- } do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Test Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
- job = insert(:job, workflow: workflow)
-
- user_with_prefs =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{
- "prefer_legacy_editor" => true
- }
- })
- |> Lightning.Repo.update!()
-
- # Try to navigate to collaborative editor with query params
- {:error, {:live_redirect, %{to: redirect_path}}} =
- conn
- |> log_in_user(user_with_prefs)
- |> live(
- ~p"/projects/#{project.id}/w/#{workflow.id}?job=#{job.id}&panel=editor"
- )
-
- # Should redirect to legacy editor with query params transformed
- assert String.starts_with?(
- redirect_path,
- "/projects/#{project.id}/w/#{workflow.id}/legacy"
- )
-
- assert redirect_path =~ "s=#{job.id}"
- assert redirect_path =~ "m=expand"
- end
-
- test "does not redirect when user does not prefer legacy editor", %{
- conn: conn
- } do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Test Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
-
- # Set user preference to NOT prefer legacy editor
- user_with_prefs =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{
- "prefer_legacy_editor" => false
- }
- })
- |> Lightning.Repo.update!()
-
- # Navigate to collaborative editor
- {:ok, _view, html} =
- conn
- |> log_in_user(user_with_prefs)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}")
-
- # Should stay on collaborative editor (no redirect)
- assert html =~ "collaborative-editor-react"
- end
-
- test "does not redirect when preference is not set", %{conn: conn} do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Test Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
-
- # No preference set (default behavior)
- conn = log_in_user(conn, user)
-
- # Navigate to collaborative editor
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}"
- )
-
- # Should stay on collaborative editor (no redirect)
- assert html =~ "collaborative-editor-react"
- end
- end
end
diff --git a/test/lightning_web/live/workflow_live/edit_template_test.exs b/test/lightning_web/live/workflow_live/edit_template_test.exs
deleted file mode 100644
index 79bab693b15..00000000000
--- a/test/lightning_web/live/workflow_live/edit_template_test.exs
+++ /dev/null
@@ -1,482 +0,0 @@
-defmodule LightningWeb.WorkflowLive.EditTemplateTest do
- use LightningWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
- import Lightning.Factories
- import Lightning.WorkflowLive.Helpers
-
- setup :register_and_log_in_support_user
- setup :create_project_for_current_user
- setup :create_workflow
-
- describe "template publishing" do
- test "publishes a new template", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- # Send tags in the form submit directly
- template_params = %{
- "workflow_template" => %{
- "name" => "My Template",
- "description" => "A template description",
- "tags" => "tag1,tag2"
- }
- }
-
- assert view
- |> form("#workflow-template-form")
- |> render_submit(template_params) =~
- "Workflow published as template"
-
- template =
- Lightning.WorkflowTemplates.get_template_by_workflow_id(workflow.id)
-
- assert template.name == "My Template"
- assert template.description == "A template description"
- assert template.tags == ["tag1", "tag2"]
- assert is_nil(template.positions)
- end
-
- test "saves node positions to templates", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- workflow_positions = %{"some-uuid" => %{"x" => 100, "y" => 100}}
-
- workflow
- |> Ecto.Changeset.change(%{positions: workflow_positions})
- |> Lightning.Repo.update!()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- assert view
- |> form("#workflow-template-form")
- |> render_submit(%{
- "workflow_template" => %{
- "name" => "My Template",
- "description" => "A template description",
- "tags" => "tag1,tag2"
- }
- }) =~
- "Workflow published as template"
-
- template =
- Lightning.WorkflowTemplates.get_template_by_workflow_id(workflow.id)
-
- assert template.name == "My Template"
- assert template.description == "A template description"
- assert template.tags == ["tag1", "tag2"]
- assert template.positions == workflow_positions
- end
-
- test "updates an existing template", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- template =
- insert(:workflow_template,
- workflow: workflow,
- name: "Old Name",
- tags: []
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- template_params = %{
- "workflow_template" => %{
- "name" => "Updated Name",
- "description" => "Updated description",
- "tags" => "updated,tags"
- }
- }
-
- assert view
- |> form("#workflow-template-form")
- |> render_submit(template_params) =~ "Workflow template updated"
-
- updated_template = Lightning.WorkflowTemplates.get_template(template.id)
- assert updated_template.name == "Updated Name"
- assert updated_template.description == "Updated description"
- assert updated_template.tags == ["updated", "tags"]
- end
-
- test "validates template form", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- assert view
- |> form("#workflow-template-form", %{
- "workflow_template" => %{"name" => ""}
- })
- |> render_submit() =~ "This field can't be blank"
- end
-
- test "cancels template publishing", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- view |> element("#cancel-template-publish") |> render_click()
-
- refute view |> element("#workflow-template-form") |> has_element?()
- end
-
- test "disables publish button when workflow has unsaved changes", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: "New Name"})
-
- assert view |> element("#publish-template-btn[disabled]") |> has_element?()
- end
-
- test "validates template name length", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- long_name = String.duplicate("a", 256)
-
- assert view
- |> form("#workflow-template-form", %{
- "workflow_template" => %{"name" => long_name}
- })
- |> render_submit() =~ "Name must be less than 255 characters"
- end
-
- test "validates template description length", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- view |> element("#publish-template-btn") |> render_click()
-
- long_description = String.duplicate("a", 1001)
-
- assert view
- |> form("#workflow-template-form", %{
- "workflow_template" => %{
- "name" => "Valid Name",
- "description" => long_description
- }
- })
- |> render_submit() =~
- "Description must be less than 1000 characters"
- end
-
- test "prevents publishing with unsaved changes", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- # Make unsaved changes
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: "New Name"})
-
- # Verify the publish button is disabled
- assert view |> element("#publish-template-btn[disabled]") |> has_element?()
-
- # Verify the template form is not rendered
- refute view |> element("#workflow-template-form") |> has_element?()
- end
-
- test "does not show publish button for non-support users", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- # Create a non-support user and add them to the project
- user = insert(:user, support_user: false)
- insert(:project_user, user: user, project: project, role: :editor)
- conn = log_in_user(conn, user)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
- )
-
- render_hook(view, "workflow_code_generated", %{
- "code" => "test workflow code",
- "code_with_ids" => "test workflow code with ids"
- })
-
- refute view |> element("#publish-template-btn") |> has_element?()
- end
- end
-
- describe "tag input component" do
- test "renders with empty tags" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- value: []
- })
-
- assert html =~ ~s{id="test-tags-container"}
- assert html =~ ~s{id="test-tags_raw"}
- assert html =~ ~s{id="test-tags"}
- assert html =~ ~s{value=""}
- assert html =~ ~s{class="tag-list mt-2"}
- refute html =~ ~s{}
-
- assert html =~
- ~s{}
-
- assert html =~
- ~s{}
- end
-
- test "renders with comma-separated string of tags" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- value: "tag1,tag2,tag3"
- })
-
- assert html =~ ~s{id="test-tags-container"}
- assert html =~ ~s{id="test-tags_raw"}
- assert html =~ ~s{id="test-tags"}
- assert html =~ ~s{value="tag1,tag2,tag3"}
- assert html =~ ~s{class="tag-list mt-2"}
-
- assert html =~
- ~s{}
-
- assert html =~
- ~s{}
-
- assert html =~
- ~s{}
- end
-
- test "renders with trimmed tags" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- value: " tag1 , tag2 , tag3 "
- })
-
- assert html =~ ~s{id="test-tags-container"}
- assert html =~ ~s{id="test-tags_raw"}
- assert html =~ ~s{id="test-tags"}
- assert html =~ ~s{value="tag1,tag2,tag3"}
- assert html =~ ~s{class="tag-list mt-2"}
-
- assert html =~
- ~s{}
-
- assert html =~
- ~s{}
-
- assert html =~
- ~s{}
- end
-
- test "renders with label and required indicator" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- label: "Tags",
- required: true
- })
-
- assert html =~ ~s{ * }
- end
-
- test "renders with sublabel" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- sublabel: "Add tags separated by commas"
- })
-
- assert html =~ ~s{}
- assert html =~ ~s{Add tags separated by commas}
- end
-
- test "renders with placeholder" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- placeholder: "Enter tags..."
- })
-
- assert html =~ ~s{placeholder="Enter tags..."}
- end
-
- test "renders with errors" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- errors: ["Tags are required"]
- })
-
- assert html =~
- ~s{border-danger-400 focus:border-danger-400 focus:outline-danger-400}
-
- assert html =~ ~s{Tags are required}
- end
-
- test "renders with standalone mode" do
- html =
- render_component(&LightningWeb.Components.NewInputs.input/1, %{
- type: "tag",
- id: "test-tags",
- name: "test_tags",
- standalone: true
- })
-
- assert html =~ ~s{data-standalone-mode}
- end
- end
-end
diff --git a/test/lightning_web/live/workflow_live/edit_test.exs b/test/lightning_web/live/workflow_live/edit_test.exs
deleted file mode 100644
index 658774325eb..00000000000
--- a/test/lightning_web/live/workflow_live/edit_test.exs
+++ /dev/null
@@ -1,5173 +0,0 @@
-defmodule LightningWeb.WorkflowLive.EditTest do
- use LightningWeb.ConnCase, async: true
-
- import Ecto.Query
- import Eventually
- import ExUnit.CaptureLog
- import Lightning.Factories
- import Lightning.JobsFixtures
- import Lightning.WorkflowLive.Helpers
- import Lightning.WorkflowsFixtures
- import Lightning.GithubHelpers
- import Phoenix.LiveViewTest
- import Mox
-
- alias Lightning.Auditing.Audit
- alias Lightning.Helpers
- alias Lightning.Repo
-
- setup :stub_apollo_unavailable
- alias Lightning.Workflows
- alias Lightning.Workflows.Presence
- alias Lightning.Workflows.Snapshot
- alias Lightning.Workflows.Workflow
- alias LightningWeb.CredentialLiveHelpers
-
- setup :register_and_log_in_user
- setup :create_project_for_current_user
-
- describe "initial YAML generation" do
- setup :create_workflow
-
- test "pushes generate_workflow_code on first mount", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert_push_event(view, "generate_workflow_code", %{})
- end
-
- test "fires after a new workflow is created on the canvas", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise)
-
- select_template(view, "base-webhook-template")
- render_click(view, "save")
-
- assert_push_event(view, "generate_workflow_code", %{})
- end
- end
-
- describe "New credential from project context " do
- setup %{project: project} do
- %{job: job} = workflow_job_fixture(project_id: project.id)
- workflow = Repo.get(Workflow, job.workflow_id)
-
- {:ok, snapshot} = Workflows.Snapshot.create(workflow)
-
- %{job: job, workflow: workflow, snapshot: snapshot}
- end
-
- test "open credential modal from the job inspector (edit_workflow)", %{
- conn: conn,
- project: project,
- job: job,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{job.workflow_id}/legacy?s=#{job.id}&v=#{workflow.lock_version}",
- on_error: :raise
- )
-
- assert has_element?(view, "#job-pane-#{job.id}")
-
- view |> element("#new-credential-button") |> render_click()
-
- assert has_element?(view, "#credential-schema-picker")
- view |> CredentialLiveHelpers.select_credential_type("http")
- view |> CredentialLiveHelpers.click_continue()
-
- refute has_element?(view, "#project_list")
- end
-
- test "create new credential from job inspector and update the job form", %{
- conn: conn,
- project: project,
- job: job,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{job.workflow_id}/legacy?s=#{job.id}&v=#{workflow.lock_version}",
- on_error: :raise
- )
-
- view |> element("#new-credential-button") |> render_click()
-
- view |> CredentialLiveHelpers.select_credential_type("raw")
- view |> CredentialLiveHelpers.click_continue()
-
- view
- |> form("#credential-form-new",
- credential: %{
- name: "newly created credential",
- body: Jason.encode!(%{"a" => 1})
- }
- )
- |> render_submit()
-
- refute has_element?(view, "#credential-form")
-
- assert view
- |> has_element?(
- ~S{select[name='credential_selector'] option},
- "newly created credential"
- ),
- "Should have the project credential available"
- end
- end
-
- describe "new" do
- test "builds a new workflow", %{conn: conn, project: project} do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise)
-
- select_template(view, "base-webhook-template")
-
- # Naively add a job via the editor (calling the push-change event)
- assert view
- |> push_patches_to_view([add_job_patch()])
-
- # The server responds with a patch with any further changes
- assert_reply(
- view,
- %{
- patches: [
- %{op: "add", path: "/jobs/0/project_credential_id", value: nil},
- %{op: "add", path: "/jobs/0/keychain_credential_id", value: nil},
- %{
- op: "add",
- path: "/jobs/0/errors",
- value: %{
- "body" => ["Code editor cannot be empty."],
- "name" => ["Job name can't be blank."]
- }
- },
- %{op: "add", path: "/jobs/0/body", value: ""},
- %{
- op: "add",
- path: "/jobs/0/adaptor",
- value: "@openfn/language-common@latest"
- },
- %{
- op: "add",
- path: "/errors/jobs",
- value: [
- %{
- "body" => ["Code editor cannot be empty."],
- "name" => ["Job name can't be blank."]
- },
- %{}
- ]
- }
- ]
- }
- )
- end
-
- @tag role: :editor
- test "creating a new workflow", %{conn: conn, project: project} do
- Mox.verify_on_exit!()
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise)
-
- {view, parsed_template} = select_template(view, "base-webhook-template")
-
- workflow_name = view |> get_workflow_params() |> Map.get("name")
-
- assert workflow_name == parsed_template["name"]
-
- # save button is not present
- refute view
- |> element("button[type='submit'][form='workflow-form'][disabled]")
- |> has_element?()
-
- refute view
- |> element("button[type='submit'][form='workflow-form']")
- |> has_element?()
-
- # settings panel is not preset
- refute has_element?(view, "#toggle-settings")
-
- # selecting a job doesn't open the panel
- {job, _, _} = select_first_job(view)
- path = assert_patch(view)
-
- # this v=0 is not actually what happens in the UI. The test helper select_first_job blindly
- # passes the workflow_version
- assert path ==
- ~p"/projects/#{project.id}/w/new/legacy?s=#{job.id}&v=0"
-
- refute render(view) =~ "Job Name"
- refute has_element?(view, "input[name='workflow[jobs][0][name]']")
-
- # the panel for creating workflow appears
- html = render(view)
- assert html =~ "Describe your workflow"
- assert has_element?(view, "form#search-templates-form")
- assert has_element?(view, "form#choose-workflow-template-form")
-
- # click continue
- view |> render_click("save")
-
- workflow = get_assigns(view) |> Map.get(:workflow)
-
- # now let's fill in the name
- workflow_name = "My Workflow"
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: workflow_name})
-
- # the panel disappears
- html = render(view)
- refute html =~ "Describe your workflow"
- refute has_element?(view, "form#search-templates-form")
- refute has_element?(view, "form#choose-workflow-template-form")
-
- # save button is now present
- assert view
- |> element("button", "Save")
- |> has_element?()
-
- # toggle settings panel button is now preset
- assert has_element?(view, "#toggle-settings")
-
- # selecting a job now opens the panel
- {job, _, _} = select_first_job(view)
- path = assert_patch(view)
-
- assert path ==
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&v=#{workflow.lock_version - 1}"
-
- assert render(view) =~ "Job Name"
- assert has_element?(view, "input[name='workflow[jobs][0][name]']")
-
- view |> fill_job_fields(job, %{name: "My Job"})
-
- # this has been inversed. ideally, it should not select latest by default
- # but given that @latest is set in the Job schema, it will alwasy get selected
- assert view |> selected_adaptor_version_element(job) |> render() =~
- ~r(value="@openfn/[a-z-]+@latest"),
- "should have @latest selected by default"
-
- view |> element("#new-credential-button") |> render_click()
-
- view |> CredentialLiveHelpers.select_credential_type("dhis2")
-
- view |> CredentialLiveHelpers.click_continue()
-
- # Creating a new credential from the Job panel
- view
- |> CredentialLiveHelpers.fill_credential(%{
- name: "My Credential",
- body: %{username: "foo", password: "bar", hostUrl: "http://someurl"}
- })
-
- view |> CredentialLiveHelpers.click_save()
-
- assert view |> selected_credential_name(job) == "My Credential"
-
- # Editing the Jobs' body
- view |> click_edit(job)
-
- view |> change_editor_text("some body")
-
- close_job_edit_view(view, job)
-
- # By default, workflows are disabled to ensure a controlled setup.
- # Here, we enable the workflow to test the :too_many_workflows limit action
- view
- |> element("#toggle-control-workflow")
- |> render_click()
-
- refute view |> save_is_disabled?()
-
- assert view |> has_pending_changes()
-
- # Try saving with the limitter
- error_msg = "Oopsie Doopsie! An error occured"
-
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- 1,
- fn %{type: :activate_workflow}, _context ->
- {:error, :too_many_workflows, %{text: error_msg}}
- end
- )
-
- html = click_save(view)
-
- assert html =~ error_msg
-
- # let return ok with the limitter
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- 1,
- fn %{type: :activate_workflow}, _context -> :ok end
- )
-
- # subscribe to workflow events
- Lightning.Workflows.subscribe(project.id)
-
- click_save(view)
-
- assert %{id: workflow_id} =
- Lightning.Repo.one(
- from(w in Workflow,
- where:
- w.project_id == ^project.id and w.name == ^workflow_name
- )
- )
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow_id}/legacy?#{[s: job.id]}"
- )
-
- assert render(view) =~ "Workflow saved"
-
- # workflow updated event is emitted
- assert_received %Lightning.Workflows.Events.WorkflowUpdated{
- workflow: %{id: ^workflow_id}
- }
- end
-
- @tag role: :editor
- test "creating a new workflow via template copies the name of the template",
- %{conn: conn, project: project} do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise)
-
- select_template(view, "base-webhook-template")
-
- # the panel for creating workflow is visible
- html = render(view)
- assert html =~ "Describe your workflow"
- assert has_element?(view, "form#search-templates-form")
- assert has_element?(view, "form#choose-workflow-template-form")
-
- # lets select the cron one
- template_id = "base-cron-template"
- cron_template_name = "Scheduled Workflow"
-
- view
- |> form("#choose-workflow-template-form", %{template_id: template_id})
- |> render_change()
-
- assert view
- |> element(
- "form#choose-workflow-template-form label[data-selected='true']"
- )
- |> render() =~ cron_template_name
-
- # lets dummy send the content or base template
- job_id = Ecto.UUID.generate()
- trigger_id = Ecto.UUID.generate()
-
- payload = %{
- "triggers" => [%{"id" => trigger_id, "type" => "webhook"}],
- "jobs" => [
- %{
- "id" => job_id,
- "name" => "random job",
- "body" => "// comment"
- }
- ],
- "edges" => [
- %{
- "id" => Ecto.UUID.generate(),
- "source_trigger_id" => trigger_id,
- "condition_type" => "always",
- "target_job_id" => job_id
- }
- ]
- }
-
- view
- |> with_target("#new-workflow-panel")
- |> render_click("template-parsed", %{"workflow" => payload})
-
- # click continue
- view |> element("button#create_workflow_btn") |> render_click()
-
- click_save(view)
-
- expected_workflow_name = "Untitled workflow"
-
- assert Lightning.Repo.exists?(
- from(w in Workflow,
- where:
- w.project_id == ^project.id and
- w.name == ^expected_workflow_name
- )
- )
- end
-
- @tag role: :editor
- test "creating a new workflow via import handles empty name",
- %{conn: conn, project: project} do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=import",
- on_error: :raise
- )
-
- # Generate IDs for the workflow components
- job_id = Ecto.UUID.generate()
- trigger_id = Ecto.UUID.generate()
-
- # Send workflow with empty name
- view
- |> with_target("#new-workflow-panel")
- |> render_click("workflow-parsed", %{
- "workflow" => %{
- "name" => "",
- "triggers" => [%{"id" => trigger_id, "type" => "webhook"}],
- "jobs" => [
- %{
- "id" => job_id,
- "name" => "random job",
- "body" => "// comment"
- }
- ],
- "edges" => [
- %{
- "id" => Ecto.UUID.generate(),
- "source_trigger_id" => trigger_id,
- "condition_type" => "always",
- "target_job_id" => job_id
- }
- ]
- }
- })
-
- # click continue
- view |> element("button#create_workflow_btn") |> render_click()
-
- click_save(view)
-
- expected_workflow_name = "Untitled workflow"
-
- assert Lightning.Repo.exists?(
- from(w in Workflow,
- where:
- w.project_id == ^project.id and
- w.name == ^expected_workflow_name
- )
- )
- end
-
- @tag role: :editor
- test "creating a new workflow via import", %{conn: conn, project: project} do
- {:ok, view, _html} =
- conn
- |> live(~p"/projects/#{project}/w/new/legacy")
-
- assert view
- |> element("#import-workflow-btn")
- |> render_click() =~ "Paste your YAML content here"
-
- # Test with valid payload
- job_id = Ecto.UUID.generate()
- trigger_id = Ecto.UUID.generate()
- edge_id = Ecto.UUID.generate()
-
- valid_payload = %{
- "name" => "Test Workflow",
- "jobs" => [
- %{
- "id" => job_id,
- "name" => "Test Job",
- "adaptor" => "@openfn/language-common@latest",
- "body" => "fn(state => state)"
- }
- ],
- "triggers" => [
- %{
- "id" => trigger_id,
- "type" => "webhook",
- "enabled" => true
- }
- ],
- "edges" => [
- %{
- "id" => edge_id,
- "source_trigger_id" => trigger_id,
- "target_job_id" => job_id,
- "condition_type" => "always",
- "enabled" => true
- }
- ]
- }
-
- view
- |> with_target("#new-workflow-panel")
- |> render_click("workflow-parsed", %{"workflow" => valid_payload})
-
- refute view
- |> element("#create_workflow_btn")
- |> render() =~ "disabled=\"disabled\""
-
- # Test with invalid payload (missing required fields)
- invalid_payload = %{
- "jobs" => [
- %{
- "id" => Ecto.UUID.generate(),
- "name" => "Test Job"
- }
- ]
- }
-
- view |> render_click("choose-another-method", %{"method" => "import"})
-
- view
- |> with_target("#new-workflow-panel")
- |> render_click("workflow-parsed", %{"workflow" => invalid_payload})
-
- assert view
- |> element("#create_workflow_btn")
- |> render() =~ "disabled=\"disabled\""
- end
-
- @tag role: :editor
- test "auditing snapshot creation", %{
- conn: conn,
- project: project,
- user: %{id: user_id}
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- {view, _parsed_workflow} = select_template(view, "base-cron-template")
-
- view |> render_click("save")
-
- workflow_name = "My Workflow"
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: workflow_name})
-
- {job, _, _} = view |> select_first_job()
-
- view |> fill_job_fields(job, %{name: "My Job"})
-
- view |> element("#new-credential-button") |> render_click()
-
- view |> CredentialLiveHelpers.select_credential_type("dhis2")
-
- view |> CredentialLiveHelpers.click_continue()
-
- # Creating a new credential from the Job panel
- view
- |> CredentialLiveHelpers.fill_credential(%{
- name: "My Credential",
- body: %{username: "foo", password: "bar", hostUrl: "http://someurl"}
- })
-
- view |> CredentialLiveHelpers.click_save()
-
- # Editing the Jobs' body
- view |> click_edit(job)
-
- view |> change_editor_text("some body")
-
- view |> render_click("save")
-
- assert %{id: workflow_id} =
- Lightning.Repo.one(
- from(w in Workflow,
- where:
- w.project_id == ^project.id and w.name == ^workflow_name
- )
- )
-
- audit_query = from(a in Audit, where: a.event == "snapshot_created")
-
- audit_events = Lightning.Repo.all(audit_query)
-
- # There should be 2 audit events - one for initial creation, one for save-and-sync
- assert length(audit_events) == 2
-
- Enum.each(audit_events, fn audit_event ->
- assert %{
- actor_id: ^user_id,
- item_id: ^workflow_id,
- item_type: "workflow"
- } = audit_event
- end)
- end
-
- @tag role: :viewer
- test "viewers can't create new workflows", %{conn: conn, project: project} do
- {:ok, _view, html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise)
- |> follow_redirect(conn, ~p"/projects/#{project.id}/w")
-
- assert html =~ "You are not authorized to perform this action."
- end
- end
-
- describe "edit" do
- setup :create_workflow
-
- test "renders breadcrumb navigation with Workflows link", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, _view, html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- # Verify the Workflows breadcrumb is rendered (else branch of breadcrumb loop)
- assert html =~ "Workflows"
- # Verify the project picker is rendered (if branch of breadcrumb loop)
- assert html =~ "breadcrumb-project-picker-trigger"
- end
-
- test "Editing tracks user presence", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- assert [] = Presence.list_presences_for(workflow)
- refute Presence.has_any_presence?(workflow)
-
- {:ok, _view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- user = Map.put(user, :password, nil)
-
- assert [%Presence{user: ^user}] = Presence.list_presences_for(workflow)
- assert Presence.has_any_presence?(workflow)
- end
-
- test "Switching trigger types doesn't erase webhook URL input content", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- select_trigger(view)
-
- trigger = List.first(workflow.triggers)
- webhook_url = url(LightningWeb.Endpoint, ~p"/i/#{trigger.id}")
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{"triggers" => %{"0" => %{"type" => "cron"}}}
- })
- |> render_change()
-
- click_save(view)
-
- refute view |> has_element?("#webhookUrlInput[value='#{webhook_url}']")
-
- select_trigger(view)
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{"triggers" => %{"0" => %{"type" => "webhook"}}}
- })
- |> render_change()
-
- click_save(view)
-
- assert view |> has_element?("#webhookUrlInput[value='#{webhook_url}']")
- end
-
- test "Switching between workflow versions maintains correct read-only and edit modes",
- %{
- conn: conn,
- project: project,
- snapshot: snapshot,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert snapshot.lock_version == workflow.lock_version
-
- assert view
- |> has_element?(
- "[id='canvas-workflow-version'][aria-label='This is the latest version of this workflow']",
- "latest"
- )
-
- refute view
- |> has_element?(
- "[id='version-switcher-canvas-#{workflow.id}][data-version='latest']"
- )
-
- view |> fill_workflow_name("#{workflow.name} v2")
-
- workflow.jobs
- |> Enum.with_index()
- |> Enum.each(fn {job, idx} ->
- view |> select_node(job, workflow.lock_version)
-
- refute view
- |> has_element?("[id='workflow_jobs_#{idx}_name'][disabled]")
-
- refute view |> has_element?("[id='adaptor-name'][disabled]")
- refute view |> has_element?("[id='adaptor-version'][disabled]")
-
- refute view
- |> has_element?(
- "[id='workflow_jobs_#{idx}_project_credential_id'][disabled]"
- )
-
- view |> click_edit(job)
-
- assert view
- |> has_element?(
- "[id='inspector-workflow-version'][aria-label='This is the latest version of this workflow']",
- "latest"
- )
-
- refute view
- |> has_element?("[id='manual_run_form_dataclip_id'][disabled]")
-
- refute view
- |> has_element?(
- "[id='job-editor-#{job.id}'][data-disabled='true']"
- )
-
- refute view
- |> has_element?("[id='version-switcher-inspector-#{job.id}]")
-
- refute view
- |> has_element?(
- "[type='submit'][form='workflow-form'][disabled]",
- "Save"
- )
- end)
-
- workflow.edges
- |> Enum.with_index()
- |> Enum.each(fn {edge, idx} ->
- view |> select_node(edge, workflow.lock_version)
-
- refute view
- |> has_element?(
- "[id='workflow_edges_#{idx}_condition_type'][disabled]"
- )
- end)
-
- workflow.triggers
- |> Enum.with_index()
- |> Enum.each(fn {trigger, idx} ->
- view |> select_node(trigger, workflow.lock_version)
-
- refute view
- |> has_element?("[id='triggerType'][disabled]")
-
- refute view
- |> has_element?(
- "[id='workflow_triggers_#{idx}_enabled'][disabled]"
- )
- end)
-
- job_1 = List.first(workflow.jobs)
-
- view |> select_node(job_1, workflow.lock_version)
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "jobs" => %{
- "0" => %{
- "name" => "#{job_1.name} v2"
- }
- }
- }
- })
- |> render_change()
-
- view
- |> form("#workflow-form")
- |> render_submit()
-
- workflow = Repo.reload!(workflow)
-
- assert snapshot.lock_version < workflow.lock_version
-
- version = String.slice(snapshot.id, 0..6)
-
- view
- |> element(
- "a[href='/projects/#{project.id}/w'][data-phx-link='redirect']",
- "Workflows"
- )
- |> render_click()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: snapshot.lock_version]}",
- on_error: :raise
- )
-
- assert view
- |> has_element?(
- "[id='canvas-workflow-version'][aria-label='You are viewing a snapshot of this workflow that was taken on #{Helpers.format_date(snapshot.inserted_at, "%F at %T")}']",
- version
- )
-
- assert view
- |> has_element?(
- "[id='version-switcher-button-#{workflow.id}']",
- "Switch to latest version"
- )
-
- snapshot.jobs
- |> Enum.with_index()
- |> Enum.each(fn {job, idx} ->
- view |> select_node(job, workflow.lock_version)
-
- assert view
- |> has_element?(
- "input[name='snapshot[jobs][#{idx}][name]'][disabled]"
- )
-
- assert view |> has_element?("[id='adaptor-name'][disabled]")
- assert view |> has_element?("[id='adaptor-version'][disabled]")
-
- assert view
- |> has_element?("select[name='credential_selector'][disabled]")
-
- view |> click_edit(job)
-
- assert view
- |> has_element?(
- "[id='inspector-workflow-version'][aria-label='You are viewing a snapshot of this workflow that was taken on #{Helpers.format_date(snapshot.inserted_at, "%F at %T")}']",
- version
- )
-
- view
- |> has_element?("[id='manual_run_form_dataclip_id'][disabled]")
-
- # TODO: There is an issue with the new jsx approach, this attribute
- # is no longer present in the DOM. It looks like LiveView doesn't
- # render script tags while testing.
- # It should look a little bit like this when runnin the server:
- #
-
- # assert view
- # |> has_element?(
- # "[id='job-editor-#{job.id}'][data-disabled='true'][data-disabled-message=\"You can't edit while viewing a snapshot, switch to the latest version.\""
- # )
-
- assert view
- |> has_element?("[id='version-switcher-toggle-#{job.id}]")
-
- assert view |> save_is_disabled?()
- end)
-
- snapshot.edges
- |> Enum.with_index()
- |> Enum.each(fn {edge, idx} ->
- view |> select_node(edge, workflow.lock_version)
-
- assert view
- |> has_element?(
- "select[name='snapshot[edges][#{idx}][condition_type]'][disabled]"
- )
- end)
-
- snapshot.triggers
- |> Enum.with_index()
- |> Enum.each(fn {trigger, idx} ->
- view |> select_node(trigger, workflow.lock_version)
-
- assert view
- |> has_element?("[id='triggerType'][disabled]")
-
- assert view
- |> has_element?(
- "input[name='snapshot[triggers][#{idx}][enabled]'][disabled]"
- )
- end)
-
- last_job = List.last(snapshot.jobs)
- last_edge = List.last(snapshot.edges)
-
- assert force_event(view, :save) =~
- "Cannot save in snapshot mode, switch to the latest version."
-
- assert force_event(view, :delete_node, last_job) =~
- "Cannot delete a step in snapshot mode, switch to latest"
-
- view |> select_node(last_edge, snapshot.lock_version)
-
- assert force_event(view, :delete_edge, last_edge) =~
- "Cannot delete an edge in snapshot mode, switch to latest"
-
- assert force_event(view, :manual_run_submit, %{}) =~
- "Cannot run in snapshot mode, switch to latest."
-
- assert force_event(view, :rerun, nil, nil) =~
- "Cannot rerun in snapshot mode, switch to latest."
-
- assert view |> element("#edit-disabled-warning") |> render() =~
- "You cannot edit or run an old snapshot of a workflow"
-
- assert view
- |> element("#version-switcher-button-#{workflow.id}")
- |> has_element?()
-
- refute view |> element("[type='submit']", "Save") |> has_element?()
-
- view
- |> element("#version-switcher-button-#{workflow.id}")
- |> render_click()
-
- refute view |> has_element?("#edit-disabled-warning")
-
- refute render(view) =~
- "You cannot edit or run an old snapshot of a workflow"
-
- refute view
- |> element("#version-switcher-button-#{workflow.id}")
- |> has_element?()
-
- refute view |> save_is_disabled?()
- end
-
- test "Creating an audit event on rerun", %{
- conn: conn,
- project: project,
- snapshot: snapshot,
- user: %{id: user_id},
- workflow: %{id: workflow_id} = workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> fill_workflow_name("#{workflow.name} v2")
-
- job_1 = List.first(workflow.jobs)
-
- view |> select_node(job_1, workflow.lock_version)
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "jobs" => %{
- "0" => %{
- "name" => "#{job_1.name} v2"
- }
- }
- }
- })
- |> render_change()
-
- view
- |> form("#workflow-form")
- |> render_submit()
-
- workflow = Repo.reload!(workflow)
-
- view
- |> element(
- "a[href='/projects/#{project.id}/w'][data-phx-link='redirect']",
- "Workflows"
- )
- |> render_click()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: snapshot.lock_version]}",
- on_error: :raise
- )
-
- last_edge = List.last(snapshot.edges)
-
- existing_audit_ids = Audit |> Repo.all() |> Enum.map(& &1.id)
- existing_snapshot_ids = Snapshot |> Repo.all() |> Enum.map(& &1.id)
-
- view |> select_node(last_edge, snapshot.lock_version)
-
- force_event(view, :manual_run_submit, %{})
-
- force_event(view, :rerun, nil, nil)
-
- snapshots_query =
- from(s in Snapshot, where: s.id not in ^existing_snapshot_ids)
-
- [%{id: latest_snapshot_id}] = Lightning.Repo.all(snapshots_query)
-
- audit_query =
- from(a in Audit, where: a.id not in ^existing_audit_ids)
-
- [audit] = Lightning.Repo.all(audit_query)
-
- assert %{
- event: "snapshot_created",
- actor_id: ^user_id,
- item_id: ^workflow_id,
- item_type: "workflow",
- changes: %{
- after: %{"snapshot_id" => ^latest_snapshot_id}
- }
- } = audit
- end
-
- test "Inspector renders run thru their snapshots and allows switching to the latest versions for editing",
- %{
- conn: conn,
- project: project,
- snapshot: earliest_snapshot,
- user: user,
- workflow: workflow
- } do
- run_1 =
- insert(:run,
- work_order: build(:workorder, workflow: workflow),
- starting_trigger: build(:trigger),
- dataclip: build(:dataclip),
- finished_at: build(:timestamp),
- snapshot: earliest_snapshot,
- state: :started
- )
-
- jobs_attrs =
- workflow.jobs
- |> Enum.with_index()
- |> Enum.map(fn {job, idx} ->
- %{
- id: job.id,
- name: "job-number-#{idx}",
- body:
- ~s[fn(state => { console.log("job body number #{idx}"); return state; })]
- }
- end)
-
- {:ok, workflow} =
- Workflows.change_workflow(workflow, %{jobs: jobs_attrs})
- |> Workflows.save_workflow(user)
-
- latest_snapshot = Snapshot.get_current_for(workflow)
-
- run_2 =
- insert(:run,
- work_order: build(:workorder, workflow: workflow),
- starting_trigger: build(:trigger),
- dataclip: build(:dataclip),
- finished_at: build(:timestamp),
- snapshot: latest_snapshot,
- state: :started
- )
-
- job_1 = List.last(run_1.snapshot.jobs)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run_1, s: job_1, m: "expand", v: run_1.snapshot.lock_version]}",
- on_error: :raise
- )
-
- run_1_version = String.slice(run_1.snapshot.id, 0..6)
-
- assert view
- |> has_element?(
- "[id='inspector-workflow-version'][aria-label='You are viewing a snapshot of this workflow that was taken on #{Helpers.format_date(run_1.snapshot.inserted_at, "%F at %T")}']",
- run_1_version
- )
-
- assert view
- |> has_element?(
- "#job-editor-panel-panel-header-title",
- "Editor (read-only)"
- )
-
- # See: line 563
- # assert view
- # |> has_element?(
- # "[id='job-editor-#{job_1.id}'][data-disabled='true'][data-source='#{job_1.body}'][data-disabled-message=\"You can't edit while viewing a snapshot, switch to the latest version.\"]"
- # )
-
- assert view |> has_element?("div", job_1.name)
-
- view |> element("#version-switcher-toggle-#{job_1.id}") |> render_click()
-
- job_2 = List.last(run_2.snapshot.jobs)
-
- assert view
- |> has_element?(
- "[id='inspector-workflow-version'][aria-label='This is the latest version of this workflow']",
- "latest"
- )
-
- assert view
- |> has_element?(
- "#job-editor-panel-panel-header-title",
- "Editor"
- )
-
- # See: line 563
- # assert view
- # |> has_element?(
- # "[id='job-editor-#{job_1.id}'][data-disabled-message=''][data-disabled='false'][data-source='#{job_2.body}']"
- # )
-
- refute view
- |> has_element?("select[name='manual[dataclip_id]'][disabled]")
-
- assert view |> has_element?("div", job_2.name)
- end
-
- test "Can't switch to the latest version from a deleted step", %{
- conn: conn,
- project: project,
- snapshot: snapshot,
- user: user,
- workflow: workflow
- } do
- run =
- insert(:run,
- work_order: build(:workorder, workflow: workflow),
- starting_trigger: build(:trigger),
- dataclip: build(:dataclip),
- finished_at: build(:timestamp),
- snapshot: snapshot,
- state: :started
- )
-
- jobs_attrs =
- workflow.jobs
- |> Enum.with_index()
- |> Enum.map(fn {job, idx} ->
- %{
- id: job.id,
- name: "job-number-#{idx}",
- body:
- ~s[fn(state => { console.log("job body number #{idx}"); return state; })]
- }
- end)
-
- {:ok, workflow} =
- Workflows.change_workflow(workflow, %{jobs: jobs_attrs})
- |> Workflows.save_workflow(user)
-
- latest_snapshot = Snapshot.get_current_for(workflow)
-
- insert(:run,
- work_order: build(:workorder, workflow: workflow),
- starting_trigger: build(:trigger),
- dataclip: build(:dataclip),
- finished_at: build(:timestamp),
- snapshot: latest_snapshot,
- state: :started
- )
-
- job_to_delete = workflow.jobs |> List.last() |> Repo.delete!()
-
- workflow = Repo.reload(workflow) |> Repo.preload(:jobs)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job_to_delete, m: "expand", v: run.snapshot.lock_version]}",
- on_error: :raise
- )
-
- assert view
- |> has_element?(
- "[id='version-switcher-toggle-#{job_to_delete.id}'][disabled]"
- )
-
- assert view
- |> render_click("switch-version", %{"type" => "toggle"}) =~
- "Can't switch to the latest version, the job has been deleted from the workflow."
- end
-
- test "click on pencil icon activates workflow name edit mode", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- another_workflow =
- workflow_fixture(name: "A random workflow", project_id: project.id)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, m: "settings"]}",
- on_error: :raise
- )
-
- assert view |> has_element?(~s(input[name="workflow[name]"]))
-
- assert view
- |> form("#workflow-form", %{"workflow" => %{"name" => ""}})
- |> render_change() =~ "can't be blank"
-
- html =
- view
- |> form("#workflow-form", %{
- "workflow" => %{"name" => another_workflow.name}
- })
- |> render_submit()
-
- assert html =~
- "A workflow with this name already exists (possibly pending deletion) in this project."
-
- assert html =~ "Workflow could not be saved"
-
- assert view
- |> form("#workflow-form", %{
- "workflow" => %{"name" => "some new name"}
- })
- |> render_submit() =~ "Workflow saved"
- end
-
- test "using the settings panel", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- refute has_element?(view, "#workflow-settings-#{workflow.id}")
-
- view
- |> element("#toggle-settings")
- |> render_click()
-
- path = assert_patch(view)
-
- assert path ==
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings"
-
- assert has_element?(view, "#workflow-settings-#{workflow.id}")
- html = render(view)
- assert html =~ "Workflow settings"
- assert html =~ "Unlimited (up to max available)"
-
- assert view
- |> form("#workflow-form", %{"workflow" => %{"concurrency" => "0"}})
- |> render_change() =~ "must be greater than or equal to 1"
-
- assert view |> element("#workflow-form") |> render_submit() =~
- "Workflow could not be saved"
-
- assert view
- |> form("#workflow-form", %{"workflow" => %{"concurrency" => "1"}})
- |> render_change() =~ "No more than one run at a time"
-
- assert view
- |> form("#workflow-form", %{"workflow" => %{"concurrency" => "5"}})
- |> render_change() =~ "No more than 5 runs at a time"
-
- # the current implmentation simply sends `save` the event, it does
- # not submit the form. I'm mimicking that here
- assert view |> render_submit("save") =~ "Workflow saved"
-
- assert Lightning.Repo.reload(workflow).concurrency == 5
-
- assert assert_patch(view) =~
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings"
-
- assert view
- |> form("#workflow-form", %{"workflow" => %{"concurrency" => ""}})
- |> render_change() =~ "Unlimited"
-
- view |> element("#toggle-settings") |> render()
-
- view
- |> element("#toggle-settings")
- |> render_click()
-
- refute has_element?(view, "#workflow-settings-#{workflow.id}")
-
- assert assert_patch(view) ==
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy"
-
- # bring the settings panel back, so we can test that selecting something
- # else will close it
- view
- |> element("#toggle-settings")
- |> render_click()
-
- assert_patch(view)
-
- job = workflow.jobs |> Enum.at(1)
-
- view |> select_node(job)
-
- assert assert_patch(view) ==
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}"
-
- refute has_element?(view, "#workflow-settings-#{workflow.id}"),
- "should not have settings panel present"
-
- # bring it back again to test the close button
- view
- |> element("#toggle-settings")
- |> render_click()
-
- refute has_element?(view, "#job-pane-#{job.id}"),
- "should not have job pane anymore"
-
- assert assert_patch(view) ==
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings"
-
- view
- |> element("#close-panel")
- |> render_click()
-
- refute has_element?(view, "#workflow-settings-#{workflow.id}")
-
- assert assert_patch(view) ==
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy"
- end
-
- test "toggling run log settings in the settings panel", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- for {conn, _user} <- setup_project_users(conn, project, [:viewer, :editor]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- view
- |> element("#toggle-settings")
- |> render_click()
-
- assert view
- |> element("#toggle-control-toggle-workflow-logs-btn")
- |> render() =~ "opacity-50 cursor-not-allowed"
-
- assert has_element?(
- view,
- "#toggle-workflow-logs-btn"
- )
-
- assert_raise ArgumentError,
- ~r/cannot click element "#toggle-workflow-logs-btn" because it is disabled/,
- fn ->
- view
- |> element("#toggle-workflow-logs-btn")
- |> render_click()
- end
-
- GenServer.stop(view.pid)
- end
-
- for {conn, _user} <- setup_project_users(conn, project, [:admin, :owner]) do
- workflow =
- workflow
- |> Repo.reload()
- |> Ecto.Changeset.change(%{enable_job_logs: true})
- |> Repo.update!()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- view
- |> element("#toggle-settings")
- |> render_click()
-
- refute view
- |> element("#toggle-control-toggle-workflow-logs-btn")
- |> render() =~ "opacity-50 cursor-not-allowed"
-
- assert has_element?(
- view,
- "#toggle-workflow-logs-btn"
- )
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{enable_job_logs: "false"})
-
- assert workflow.enable_job_logs == true
-
- # send a save event
- view |> render_submit("save")
-
- assert assert_patch(view) =~
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings"
-
- assert Repo.reload(workflow).enable_job_logs == false
-
- GenServer.stop(view.pid)
- end
- end
-
- test "users can view workflow as code", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- for {conn, _user} <-
- setup_project_users(conn, project, [:viewer, :editor, :admin, :owner]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- view
- |> element("#toggle-settings")
- |> render_click()
-
- assert assert_patch(view) =~
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings"
-
- view |> element("a#view-workflow-as-yaml-link") |> render_click()
-
- assert assert_patch(view) =~
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code"
-
- expected_download_name =
- String.replace(workflow.name, " ", "-") <> ".yaml"
-
- assert has_element?(
- view,
- "#download-workflow-code-btn[data-file-name='#{expected_download_name}']"
- )
-
- assert has_element?(view, "#copy-workflow-code-btn")
-
- GenServer.stop(view.pid)
- end
- end
-
- test "renders error message when a job has an empty body", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- job = workflow.jobs |> Enum.at(1)
-
- view |> select_node(job, workflow.lock_version)
-
- view |> click_edit(job)
-
- view |> change_editor_text("some body")
-
- refute view |> render() =~
- "Code editor cannot be empty."
-
- view |> change_editor_text("")
-
- assert view |> render() =~
- "Code editor cannot be empty."
- end
-
- test "allows editing job name", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert view |> page_title() =~ workflow.name
-
- view |> fill_workflow_name("")
-
- job_2 = workflow.jobs |> Enum.at(1)
-
- view |> select_node(job_2, workflow.lock_version)
- view |> fill_job_fields(job_2, %{name: ""})
-
- assert view |> job_form_has_error(job_2, "name", "can't be blank")
- assert view |> save_is_disabled?()
-
- new_job_name = "My Other Job"
-
- assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~
- new_job_name
-
- assert view |> save_is_disabled?()
- end
-
- test "renders the job form correctly when local_adaptors_repos is NOT set",
- %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- job_1 = hd(workflow.jobs)
-
- view |> select_node(job_1, workflow.lock_version)
-
- adaptor_name_label =
- view |> element("label[for='adaptor-name']") |> render()
-
- assert adaptor_name_label =~ "Adaptor"
- refute adaptor_name_label =~ "Adaptor (local)"
-
- # adapter version picker is available
- assert has_element?(view, "#adaptor-version")
- end
-
- @tag :tmp_dir
- test "renders the job form correctly when local_adaptors_repos is set", %{
- conn: conn,
- project: project,
- workflow: workflow,
- tmp_dir: tmp_dir
- } do
- Mox.stub(Lightning.MockConfig, :adaptor_registry, fn ->
- [local_adaptors_repos: [tmp_dir]]
- end)
-
- expected_adaptors = ["foo", "bar", "baz"]
-
- Enum.each(expected_adaptors, fn adaptor ->
- [tmp_dir, "packages", adaptor] |> Path.join() |> File.mkdir_p!()
- end)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- job_1 = hd(workflow.jobs)
-
- view |> select_node(job_1, workflow.lock_version)
-
- adaptor_name_label =
- view |> element("label[for='adaptor-name']") |> render()
-
- assert adaptor_name_label =~ "Adaptor (local)"
-
- # version picker is not present
- refute has_element?(view, "#adaptor-version")
- end
-
- test "Save button is disabled when workflow is deleted", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- workflow
- |> Ecto.Changeset.change(%{
- deleted_at: DateTime.utc_now() |> DateTime.truncate(:second)
- })
- |> Lightning.Repo.update!()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert view |> page_title() =~ workflow.name
-
- assert view |> save_is_disabled?()
-
- # try changing the workflow name anyway
- assert render_click(view, "save", %{name: "updatename"}) =~
- "Oops! You cannot modify a deleted workflow"
- end
-
- test "opens edge Path form and saves the JS expression", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- form_html =
- view |> select_node(Enum.at(workflow.jobs, 0), workflow.lock_version)
-
- assert form_html =~ "Job Name"
- refute form_html =~ "Path"
-
- form_html =
- view |> select_node(Enum.at(workflow.edges, 0), workflow.lock_version)
-
- assert form_html =~ "Path"
-
- assert form_html =~ "Label"
-
- assert form_html =~
- ~S[Always Matches a Javascript Expression ]
-
- edge_on_edit = Enum.at(workflow.edges, 1)
- form_html = view |> select_node(edge_on_edit, workflow.lock_version)
-
- assert form_html =~
- ~S[On Success ]
-
- form_html =
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "edges" => %{"1" => %{"condition_type" => "js_expression"}}
- }
- })
- |> render_change()
-
- assert form_html =~ "Label"
-
- assert form_html =~
- ~S[Matches a Javascript Expression ]
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "edges" => %{
- "1" => %{
- "condition_label" => "My JS Expression",
- "condition_expression" => "state.data.field === 33"
- }
- }
- }
- })
- |> render_change()
-
- view
- |> form("#workflow-form")
- |> render_submit()
-
- assert Map.delete(Repo.reload!(edge_on_edit), :updated_at) ==
- Map.delete(
- Map.merge(edge_on_edit, %{
- condition_type: :js_expression,
- condition_label: "My JS Expression",
- condition_expression: "state.data.field === 33"
- }),
- :updated_at
- )
- end
-
- test "displays warning when js expression contains unwanted words", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- warning_text =
- "Warning: this expression appears to contain unsafe functions (eval, require, import, process, await) that may cause your workflow to fail"
-
- edge_to_edit = Enum.at(workflow.edges, 1)
- view |> select_node(edge_to_edit)
-
- # change to js_expression
- html =
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "edges" => %{"1" => %{"condition_type" => "js_expression"}}
- }
- })
- |> render_change()
-
- assert html =~ "Matches a Javascript Expression"
- refute html =~ warning_text
-
- html =
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "edges" => %{
- "1" => %{
- "condition_label" => "My JS Expression",
- "condition_expression" => "eval"
- }
- }
- }
- })
- |> render_change()
-
- assert html =~ warning_text
- end
-
- @tag role: :editor
- test "can delete a job", %{conn: conn, project: project, workflow: workflow} do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- [job_1, job_2] = workflow.jobs
- view |> select_node(job_1, workflow.lock_version)
-
- assert view |> delete_job_button_is_disabled?(job_1)
-
- # Test that the delete event doesn't work even if the button is disabled.
- assert view |> force_event(:delete_node, job_1) =~
- "Delete all descendant steps first."
-
- view |> select_node(job_2, workflow.lock_version)
-
- assert_patched(
- view,
- ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_2}&v=#{workflow.lock_version}"
- )
-
- refute view |> delete_job_button_is_disabled?(job_2)
-
- view |> click_delete_job(job_2)
-
- assert_push_event(view, "patches-applied", %{
- patches: [
- %{op: "remove", path: "/jobs/1"},
- %{op: "remove", path: "/edges/1"}
- ]
- })
- end
-
- @tag role: :editor
- test "cannot delete an edge between a trigger and a job", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- [trigger_edge, other_edge] = workflow.edges
-
- assert view |> select_node(other_edge, workflow.lock_version) =~
- "Delete Path"
-
- refute view |> select_node(trigger_edge, workflow.lock_version) =~
- "Delete Path"
-
- assert view |> force_event(:delete_edge, trigger_edge) =~
- "You cannot remove the first edge in a workflow."
- end
-
- @tag role: :editor
- test "can delete an edge between two jobs", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- [_trigger_edge, other_edge] = workflow.edges
-
- assert view |> select_node(other_edge, workflow.lock_version) =~
- "Delete Path"
-
- view |> select_node(other_edge, workflow.lock_version)
-
- assert_patched(
- view,
- ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{other_edge}&v=#{workflow.lock_version}"
- )
-
- view |> click_delete_edge(other_edge)
-
- assert_push_event(view, "patches-applied", %{
- patches: [
- %{op: "remove", path: "/edges/1"}
- ]
- })
- end
-
- @tag role: :viewer
- test "cannot delete edges", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- [_trigger_edge, other_edge] = workflow.edges
-
- assert view |> select_node(other_edge, workflow.lock_version) =~
- "Delete Path"
-
- view |> select_node(other_edge, workflow.lock_version)
-
- assert_patched(
- view,
- ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{other_edge}&v=#{workflow.lock_version}"
- )
-
- assert view |> delete_edge_button_is_disabled?(other_edge)
-
- assert view |> force_event(:delete_edge, other_edge) =~
- "You are not authorized to delete edges."
- end
-
- @tag role: :editor
- test "can't delete the first step in a workflow", %{
- conn: conn,
- project: project
- } do
- trigger = build(:trigger, type: :webhook)
-
- job =
- build(:job,
- body: ~s[fn(state => { return {...state, extra: "data"} })],
- name: "First Job"
- )
-
- workflow =
- build(:workflow, project: project)
- |> with_job(job)
- |> with_trigger(trigger)
- |> with_edge({trigger, job})
- |> insert()
- |> with_snapshot()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> select_node(job, workflow.lock_version)
-
- assert view |> delete_job_button_is_disabled?(job)
-
- assert view |> force_event(:delete_node, job) =~
- "You can't delete the first step in a workflow."
- end
-
- @tag role: :editor
- test "can delete a step that has already been ran", %{
- conn: conn,
- project: project
- } do
- trigger = build(:trigger, type: :webhook)
-
- [job_a, job_b] = insert_list(2, :job)
-
- workflow =
- build(:workflow)
- |> with_job(job_a)
- |> with_job(job_b)
- |> with_trigger(trigger)
- |> with_edge({trigger, job_a})
- |> with_edge({job_a, job_b})
- |> insert()
- |> with_snapshot()
-
- insert(:step, job: job_b)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> select_node(job_b, workflow.lock_version)
-
- assert_patched(
- view,
- ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_b}&v=#{workflow.lock_version}"
- )
-
- refute view |> delete_job_button_is_disabled?(job_b)
-
- view |> click_delete_job(job_b)
-
- project_id = project.id
-
- assert_push_event(view, "patches-applied", %{
- patches: [
- %{value: ^project_id, path: "/project_id", op: "replace"},
- %{op: "remove", path: "/jobs/1"},
- %{op: "remove", path: "/edges/1"}
- ]
- })
- end
-
- @tag role: :editor
- test "can't delete any job that has downstream jobs",
- %{
- conn: conn,
- project: project
- } do
- trigger = build(:trigger, type: :webhook)
-
- [job_a, job_b, job_c] = build_list(3, :job)
-
- workflow =
- build(:workflow)
- |> with_job(job_a)
- |> with_job(job_b)
- |> with_job(job_c)
- |> with_trigger(trigger)
- |> with_edge({trigger, job_a})
- |> with_edge({job_a, job_b})
- |> with_edge({job_b, job_c})
- |> insert()
- |> with_snapshot()
-
- {:ok, view, html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_a}&v=#{workflow.lock_version}",
- on_error: :raise
- )
-
- assert view |> delete_job_button_is_disabled?(job_a)
-
- assert html =~
- "You can't delete a step that other downstream steps depend on"
-
- assert view |> force_event(:delete_node, job_a) =~
- "Delete all descendant steps first"
-
- {:ok, view, html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_b}&v=#{workflow.lock_version}",
- on_error: :raise
- )
-
- assert view |> delete_job_button_is_disabled?(job_b)
-
- assert html =~
- "You can't delete a step that other downstream steps depend on"
-
- assert view |> force_event(:delete_node, job_a) =~
- "Delete all descendant steps first"
- end
-
- @tag role: :viewer
- test "viewers can't edit existing jobs", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> select_node(workflow.triggers |> Enum.at(0), workflow.lock_version)
-
- assert view |> input_is_disabled?("[name='workflow[triggers][0][type]']")
-
- view |> select_node(workflow.edges |> Enum.at(0), workflow.lock_version)
-
- assert view
- |> input_is_disabled?("[name='workflow[edges][0][condition_type]']")
-
- assert view |> save_is_disabled?()
- job_1 = workflow.jobs |> Enum.at(0)
-
- view |> select_node(job_1, workflow.lock_version)
-
- assert view |> input_is_disabled?(job_1, "name")
-
- assert view |> input_is_disabled?("[name='adaptor_picker[adaptor_name]']")
- assert view |> input_is_disabled?(job_1, "adaptor")
- assert view |> input_is_disabled?(job_1, "project_credential_id")
-
- assert view |> delete_job_button_is_disabled?(job_1)
-
- # Test that the delete event doesn't work even if the button is disabled.
- assert view |> force_event(:delete_node, job_1) =~
- "You are not authorized to perform this action."
-
- assert view |> save_is_disabled?()
-
- view |> click_close_error_flash()
-
- assert view |> force_event(:save) =~
- "You are not authorized to perform this action."
-
- view |> click_close_error_flash()
-
- assert view |> force_event(:form_changed) =~
- "You are not authorized to perform this action."
-
- view |> click_close_error_flash()
-
- assert view |> force_event(:validate) =~
- "You are not authorized to perform this action."
- end
-
- test "can enable/disable any edge between two jobs", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- edge =
- Enum.find(workflow.edges, fn edge -> edge.source_job_id != nil end)
-
- assert edge.enabled
-
- {:ok, view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{edge.id}&v=#{workflow.lock_version}",
- on_error: :raise
- )
-
- idx = get_index_of_edge(view, edge)
-
- assert html =~ "Enabled"
-
- assert view
- |> element("input[name='workflow[edges][#{idx}][enabled]']")
- |> has_element?()
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{"edges" => %{to_string(idx) => %{"enabled" => false}}}
- })
- |> render_change()
-
- view
- |> form("#workflow-form")
- |> render_submit()
-
- edge = Repo.reload!(edge)
-
- refute edge.enabled
-
- refute view
- |> element(
- "input[name='workflow[edges][#{idx}][enabled]'][checked]"
- )
- |> has_element?()
- end
-
- test "does not call the limiter when the trigger is not enabled", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- Mox.verify_on_exit!()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # We expect zero calls
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- 0,
- fn %{type: :activate_workflow}, _context ->
- {:error, :too_many_workflows, %{text: "some error message"}}
- end
- )
-
- job_2 = workflow.jobs |> Enum.at(1)
-
- view |> select_node(job_2, workflow.lock_version)
-
- new_job_name = "My Other Job"
-
- assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~
- new_job_name
-
- click_save(view)
-
- assert Lightning.Repo.reload(job_2).name == new_job_name
- end
-
- test "calls the limiter when the trigger is enabled", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- Mox.verify_on_exit!()
-
- workflow.triggers
- |> hd()
- |> Ecto.Changeset.change(%{enabled: false})
- |> Lightning.Repo.update!()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # We expect 1 call
- error_msg = "Oopsie Doopsie! An error occured"
-
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- 1,
- fn %{type: :activate_workflow}, _context ->
- {:error, :too_many_workflows, %{text: error_msg}}
- end
- )
-
- select_trigger(view)
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{"triggers" => %{"0" => %{"enabled" => true}}}
- })
- |> render_change()
-
- html = click_save(view)
-
- assert html =~ error_msg
- end
-
- test "workflows are disabled by default", %{
- conn: conn,
- user: user
- } do
- project = insert(:project, project_users: [%{user: user, role: :editor}])
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/new/legacy", on_error: :raise)
-
- select_template(view, "base-webhook-template")
-
- push_patches_to_view(view, initial_workflow_patchset(project))
-
- # click continue
- view |> element("button#create_workflow_btn") |> render_click()
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: "My Workflow"})
-
- {job, _, _} = select_first_job(view)
-
- fill_job_fields(view, job, %{name: "My Job"})
-
- click_edit(view, job)
-
- change_editor_text(view, "some body")
-
- # html = click_save(view)
- html = trigger_save(view)
-
- assert html =~
- "Workflow saved successfully. Remember to enable your workflow to run it automatically."
-
- refute Workflows.get_workflows_for(project)
- |> List.first()
- |> Map.get(:triggers)
- |> List.first()
- |> Map.get(:enabled)
- end
-
- test "when workflow is enabled, reminder flash message is not displayed for the first save",
- %{
- conn: conn,
- user: user
- } do
- project = insert(:project, project_users: [%{user: user, role: :editor}])
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/new/legacy", on_error: :raise)
-
- select_template(view, "base-webhook-template")
-
- push_patches_to_view(view, initial_workflow_patchset(project))
-
- # click continue
- view |> element("button#create_workflow_btn") |> render_click()
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: "My Workflow"})
-
- {job, _, _} = select_first_job(view)
-
- fill_job_fields(view, job, %{name: "My Job"})
-
- click_edit(view, job)
-
- change_editor_text(view, "some body")
-
- close_job_edit_view(view, job)
-
- view
- |> element("#toggle-control-workflow")
- |> render_click()
-
- html = click_save(view)
-
- refute html =~
- "Workflow saved successfully. Remember to enable your workflow to run it automatically."
-
- assert html =~
- "Workflow saved successfully."
-
- assert Workflows.get_workflows_for(project)
- |> List.first()
- |> Map.get(:triggers)
- |> List.first()
- |> Map.get(:enabled)
- end
-
- test "clicking on the toggle disables all the triggers of a workflow", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- assert workflow.triggers |> Enum.all?(& &1.enabled)
-
- view
- |> element("#toggle-control-workflow")
- |> render_click()
-
- click_save(view)
-
- workflow = Workflows.get_workflow(workflow.id, include: [:triggers])
-
- refute workflow.triggers |> Enum.any?(& &1.enabled)
- end
-
- test "workflow can still be disabled / enabled from the trigger form", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- assert workflow.triggers |> Enum.all?(& &1.enabled)
-
- select_trigger(view)
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{"triggers" => %{"0" => %{"enabled" => "false"}}}
- })
- |> render_change()
-
- click_save(view)
-
- workflow = Workflows.get_workflow(workflow.id, include: [:triggers])
-
- refute workflow.triggers |> Enum.any?(& &1.enabled)
- end
-
- test "workflow state toggle tooltip messages vary by trigger type", %{
- conn: conn,
- project: project
- } do
- cron_trigger = build(:trigger, type: :cron, enabled: false)
- webhook_trigger = build(:trigger, type: :webhook, enabled: true)
-
- job_1 = build(:job)
- job_2 = build(:job)
-
- cron_workflow =
- build(:workflow)
- |> with_job(job_1)
- |> with_trigger(cron_trigger)
- |> with_edge({cron_trigger, job_1})
- |> insert()
-
- webhook_workflow =
- build(:workflow)
- |> with_job(job_2)
- |> with_trigger(webhook_trigger)
- |> with_edge({webhook_trigger, job_2})
- |> insert()
-
- Lightning.Workflows.Snapshot.create(cron_workflow)
-
- Lightning.Workflows.Snapshot.create(webhook_workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{cron_workflow.id}/legacy",
- on_error: :raise
- )
-
- assert view
- |> has_element?(
- "#toggle-container-workflow[aria-label='This workflow is inactive (manual runs only)']"
- )
-
- view
- |> element("#toggle-control-workflow")
- |> render_click()
-
- assert view
- |> has_element?(
- "#toggle-container-workflow[aria-label='This workflow is active (cron trigger enabled)']"
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{webhook_workflow.id}/legacy",
- on_error: :raise
- )
-
- assert view
- |> has_element?(
- "#toggle-container-workflow[aria-label='This workflow is active (webhook trigger enabled)']"
- )
- end
-
- @tag skip: "component moved to react"
- test "manual run form body remains unchanged even after save workflow form is submitted",
- %{conn: conn, project: project, test: test} do
- %{jobs: [job_1, job_2 | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1, m: "expand"]}",
- on_error: :raise
- )
-
- body = Jason.encode!(%{test: test})
-
- body_part = to_string(test)
-
- refute view |> element("#manual_run_form") |> render() =~ body_part
-
- assert view
- |> form("#manual_run_form")
- |> render_change(manual: %{body: body}) =~ body_part
-
- view |> close_job_edit_view(job_1)
-
- # submit workflow form
- view |> form("#workflow-form") |> render_submit()
-
- view
- |> render_patch(
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[m: "expand", s: job_1.id]}"
- )
-
- # manual run form still has the body
- assert view |> element("#manual_run_form") |> render() =~ body_part
-
- # select another job
- select_node(view, %{id: job_2.id})
- click_edit(view, %{id: job_2.id})
-
- # manual run form body is cleared
- refute view |> element("#manual_run_form") |> render() =~ body_part
- end
- end
-
- describe "Tracking Workflow editor metrics" do
- setup :create_workflow
-
- setup context do
- Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn ->
- true
- end)
-
- current_log_level = Logger.level()
- Logger.configure(level: :info)
-
- on_exit(fn ->
- Logger.configure(level: current_log_level)
- end)
-
- context
- |> Map.merge(%{
- metrics: [
- %{
- "event" => "foo-bar-event",
- "start" => 1_737_635_739_914,
- "end" => 1_737_635_808_890
- }
- ]
- })
- end
-
- test "logs the metrics", %{
- conn: conn,
- metrics: metrics,
- project: project,
- workflow: %{id: workflow_id} = workflow
- } do
- assert [] = Presence.list_presences_for(workflow)
- refute Presence.has_any_presence?(workflow)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- fun = fn ->
- view
- |> editor_element()
- |> render_hook("workflow_editor_metrics_report", %{"metrics" => metrics})
- end
-
- assert capture_log(fun) =~ ~r/foo-bar-event/
- assert capture_log(fun) =~ ~r/#{workflow_id}/
- end
-
- @tag role: :editor
- test "can change job name, adaptor, version, and credential sequentially", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- project_credential =
- insert(:project_credential,
- project: project,
- credential: build(:credential)
- )
-
- keychain_credential =
- insert(:keychain_credential, project: project)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # Select the first job
- job = hd(workflow.jobs)
- view |> select_node(job, workflow.lock_version)
-
- # Step 1: Change job name
- new_job_name = "Updated Job Name"
-
- view
- |> form("#workflow-form", %{
- "workflow" => %{
- "jobs" => %{
- "0" => %{
- "name" => new_job_name
- }
- }
- }
- })
- |> render_change()
-
- # Step 2: Change adaptor
- view |> change_adaptor(job, "@openfn/language-dhis2")
- view |> trigger_save()
-
- # Step 3: Change adaptor version to something specific (not @latest)
- specific_version = "@openfn/language-dhis2@3.0.4"
- view |> change_adaptor_version(specific_version)
-
- assert view
- |> credential_options()
- |> Enum.reject(&(&1.text == "")) ==
- [
- %{
- text: project_credential.credential.name,
- value: project_credential.id
- },
- %{text: keychain_credential.name, value: keychain_credential.id}
- ]
-
- view |> change_credential(job, project_credential)
-
- assert view |> selected_credential_name() ==
- project_credential.credential.name
-
- view |> trigger_save()
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: job.id, v: workflow.lock_version]}"
- )
-
- job = Lightning.Repo.reload(job)
- assert job.adaptor == specific_version
- assert job.name == new_job_name
- assert job.project_credential_id == project_credential.id
-
- view |> change_credential(job, keychain_credential)
- assert view |> selected_credential_name() == keychain_credential.name
-
- view |> trigger_save()
-
- job = Lightning.Repo.reload(job)
- assert job.project_credential_id == nil
- assert job.keychain_credential_id == keychain_credential.id
- end
- end
-
- describe "Save and Sync to GitHub" do
- setup :verify_on_exit!
- setup :create_workflow
-
- setup %{project: project} do
- repo_connection =
- insert(:project_repo_connection,
- project: project,
- repo: "someaccount/somerepo",
- branch: "somebranch",
- github_installation_id: "1234",
- access_token: "someaccesstoken"
- )
-
- %{repo_connection: repo_connection}
- end
-
- @tag role: :editor
- test "is not available when project isn't connected to github", %{
- conn: conn,
- project: project,
- workflow: workflow,
- repo_connection: repo_connection
- } do
- Repo.delete!(repo_connection)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- refute view |> has_element?("button[phx-click='toggle_github_sync_modal']")
- end
-
- @tag role: :editor
- test "can be done when creating a new workflow", %{
- conn: conn,
- project: project,
- repo_connection: repo_connection
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise)
-
- {view, _parsed_workflow} = select_template(view, "base-webhook-template")
-
- view |> render_click("save")
-
- workflow = get_assigns(view) |> Map.get(:workflow)
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy"
- )
-
- workflow_name = "My Workflow"
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: workflow_name})
-
- {job, _, _} = view |> select_first_job()
-
- view |> fill_job_fields(job, %{name: "My Job"})
-
- # Editing the Jobs' body
- view |> click_edit(job)
-
- view |> change_editor_text("some body")
-
- refute view |> save_is_disabled?()
-
- assert view |> has_pending_changes()
-
- # let return ok with the limitter
- stub(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- fn _action, _context ->
- :ok
- end
- )
-
- # button to sync to github exists
- assert view |> has_element?("button[phx-click='toggle_github_sync_modal']")
-
- # verify connection
- repo_name = repo_connection.repo
- branch_name = repo_connection.branch
- installation_id = repo_connection.github_installation_id
-
- expected_default_branch = "main"
-
- expected_deploy_yml_path =
- ".github/workflows/openfn-#{repo_connection.project_id}-deploy.yml"
-
- expected_config_json_path =
- "openfn-#{repo_connection.project_id}-config.json"
-
- expected_secret_name =
- "OPENFN_#{String.replace(repo_connection.project_id, "-", "_")}_API_KEY"
-
- expect(Lightning.Tesla.Mock, :call, 6, fn
- # get installation access token.
- %{
- url:
- "https://api.github.com/app/installations/" <>
- ^installation_id <> "/access_tokens"
- },
- _opts ->
- {:ok,
- %Tesla.Env{
- status: 201,
- body: %{"token" => "some-token"}
- }}
-
- # get repo content
- %{url: "https://api.github.com/repos/" <> ^repo_name}, _opts ->
- {:ok,
- %Tesla.Env{
- status: 200,
- body: %{"default_branch" => expected_default_branch}
- }}
-
- # check if pull yml exists in the default branch
- %{
- method: :get,
- query: [{:ref, "heads/" <> ^expected_default_branch}],
- url:
- "https://api.github.com/repos/" <>
- ^repo_name <> "/contents/.github/workflows/openfn-pull.yml"
- },
- _opts ->
- {:ok, %Tesla.Env{status: 200, body: %{"sha" => "somesha"}}}
-
- # check if deploy yml exists in the target branch
- %{
- method: :get,
- query: [{:ref, "heads/" <> ^branch_name}],
- url:
- "https://api.github.com/repos/" <>
- ^repo_name <> "/contents/" <> ^expected_deploy_yml_path
- },
- _opts ->
- {:ok, %Tesla.Env{status: 200, body: %{"sha" => "somesha"}}}
-
- # check if config.json exists in the target branch
- %{
- method: :get,
- query: [{:ref, "heads/" <> ^branch_name}],
- url:
- "https://api.github.com/repos/" <>
- ^repo_name <> "/contents/" <> ^expected_config_json_path
- },
- _opts ->
- {:ok, %Tesla.Env{status: 200, body: %{"sha" => "somesha"}}}
-
- # check if api key secret exists
- %{
- method: :get,
- url:
- "https://api.github.com/repos/" <>
- ^repo_name <> "/actions/secrets/" <> ^expected_secret_name
- },
- _opts ->
- {:ok, %Tesla.Env{status: 200, body: %{}}}
- end)
-
- # click to open the github sync modal
- refute has_element?(view, "#github-sync-modal")
- render_hook(view, "toggle_github_sync_modal")
- assert has_element?(view, "#github-sync-modal")
- # modal form exists
- assert view |> has_element?("form#github-sync-modal-form")
- assert render_async(view) =~ "Save and sync changes to GitHub"
-
- expect_create_installation_token(repo_connection.github_installation_id)
- expect_get_repo(repo_connection.repo)
- expect_create_workflow_dispatch(repo_connection.repo, "openfn-pull.yml")
-
- # submit form
- view
- |> form("#github-sync-modal-form")
- |> render_submit(%{
- "github_sync" => %{"commit_message" => "some message"}
- })
-
- assert workflow =
- Lightning.Repo.one(
- from(w in Workflow,
- where:
- w.project_id == ^project.id and w.name == ^workflow_name
- )
- )
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[m: "expand", s: job.id]}"
- )
-
- assert render(view) =~ "Workflow saved and sync requested. Check the"
-
- link_to_actions =
- "https://www.github.com/" <> repo_connection.repo <> "/actions"
-
- assert has_element?(
- view,
- ~s{div[data-flash-kind='info'] [href="#{link_to_actions}"][target="_blank"]},
- "GitHub actions"
- )
-
- refute has_element?(view, "#github-sync-modal")
- end
-
- @tag :capture_log
- test "does not close the github modal when GitHub sync fails", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert view |> page_title() =~ workflow.name
-
- job_2 = workflow.jobs |> Enum.at(1)
-
- view |> select_node(job_2, workflow.lock_version)
-
- new_job_name = "My Other Job"
-
- assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~
- new_job_name
-
- refute view |> save_is_disabled?()
-
- # let return ok with the limitter
- stub(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- fn _action, _context ->
- :ok
- end
- )
-
- # return error for GitHub
- stub(Lightning.Tesla.Mock, :call, fn
- %{url: "https://api.github.com/app/installations" <> _rest}, _opts ->
- {:ok,
- %Tesla.Env{
- status: 404,
- body: %{"error" => "some-error"}
- }}
-
- %{url: "https://api.github.com/" <> _rest}, _opts ->
- {:ok,
- %Tesla.Env{
- status: 400,
- body: %{"error" => "some-error"}
- }}
- end)
-
- # click to open the github sync modal
- refute has_element?(view, "#github-sync-modal")
- render_click(view, "toggle_github_sync_modal")
- assert has_element?(view, "#github-sync-modal")
-
- # submit form
- view
- |> form("#github-sync-modal-form")
- |> render_submit(%{"github_sync" => %{"commit_message" => "some message"}})
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: job_2.id, v: workflow.lock_version]}"
- )
-
- assert render(view) =~
- "Workflow saved but not synced to GitHub. Check the project GitHub connection settings"
-
- # modal is still present
- assert has_element?(view, "#github-sync-modal")
- end
-
- test "save and sync button on the modal is disabled when verification is still going on",
- %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert view |> page_title() =~ workflow.name
-
- job_2 = workflow.jobs |> Enum.at(1)
-
- view |> select_node(job_2, workflow.lock_version)
-
- new_job_name = "My Other Job"
-
- assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~
- new_job_name
-
- refute view |> save_is_disabled?()
-
- # let return ok with the limitter
- stub(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- fn _action, _context ->
- :ok
- end
- )
-
- stub(Lightning.Tesla.Mock, :call, fn
- %{url: "https://api.github.com/app/installations" <> _rest}, _opts ->
- # sleep to block the async task
- Process.sleep(5000)
-
- {:ok,
- %Tesla.Env{
- status: 201,
- body: %{"token" => "some-token"}
- }}
- end)
-
- # click to open the github sync modal
- refute has_element?(view, "#github-sync-modal")
- render_click(view, "toggle_github_sync_modal")
- assert has_element?(view, "#github-sync-modal")
-
- assert view
- |> element("button#submit-btn-github-sync-modal")
- |> render() =~ "disabled=\"disabled\""
- end
- end
-
- describe "Allow low priority access users to retry steps and create workorders" do
- setup do
- project = insert(:project)
-
- high_priority_user =
- insert(:user,
- email: "amy@openfn.org",
- first_name: "Amy",
- last_name: "Ly"
- )
-
- low_priority_user =
- insert(:user,
- email: "ana@openfn.org",
- first_name: "Ana",
- last_name: "Ba"
- )
-
- insert(:project_user,
- project: project,
- user: high_priority_user,
- role: :admin
- )
-
- insert(:project_user,
- project: project,
- user: low_priority_user,
- role: :admin
- )
-
- workflow = insert(:simple_workflow, project: project)
-
- {:ok, snapshot} = Snapshot.create(workflow)
-
- %{jobs: [job], triggers: [trigger]} = workflow
-
- [input_dataclip, output_dataclip] =
- insert_list(2, :dataclip,
- body: %{player: "sadio mane"},
- project: workflow.project
- )
-
- %{runs: [run]} =
- insert(:workorder,
- trigger: trigger,
- dataclip: input_dataclip,
- workflow: workflow,
- snapshot: snapshot,
- state: :success,
- runs: [
- build(:run,
- starting_trigger: trigger,
- dataclip: input_dataclip,
- steps: [
- build(:step,
- input_dataclip: input_dataclip,
- output_dataclip: output_dataclip,
- job: job,
- inserted_at: Timex.now() |> Timex.shift(seconds: -10),
- started_at: Timex.now() |> Timex.shift(seconds: -10),
- snapshot: snapshot
- )
- ],
- inserted_at: Timex.now() |> Timex.shift(seconds: -12),
- snapshot: snapshot,
- state: :success
- )
- ]
- )
-
- %{
- project: project,
- high_priority_user: high_priority_user,
- low_priority_user: low_priority_user,
- workflow: workflow,
- snapshot: snapshot,
- run: run,
- job: job
- }
- end
-
- test "Users with low priority access to the workflow canvas will automatically be locked in a snapshot when the high prior uses saves the workflow",
- %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot,
- run: run,
- job: job,
- high_priority_user: high_priority_user,
- low_priority_user: low_priority_user
- } do
- {high_priority_view, low_priority_view} =
- access_views(
- conn,
- project,
- workflow,
- run,
- job,
- high_priority_user,
- low_priority_user
- )
-
- assert high_priority_view
- |> has_element?("#inspector-workflow-version", "latest")
-
- assert low_priority_view
- |> has_element?("#inspector-workflow-version", "latest")
-
- high_priority_view |> select_node(%{id: job.id})
-
- high_priority_view |> click_edit(%{id: job.id})
-
- high_priority_view |> change_editor_text("Job expression 1")
-
- trigger_save(high_priority_view)
-
- assert high_priority_view
- |> has_element?("#inspector-workflow-version", "latest")
-
- refute_eventually(
- low_priority_view
- |> has_element?("#inspector-workflow-version", "latest"),
- 30_000
- )
-
- assert low_priority_view
- |> has_element?(
- "#inspector-workflow-version",
- "#{String.slice(snapshot.id, 0..6)}"
- )
-
- assert low_priority_view |> render() =~
- "This workflow has been updated. You're no longer on the latest version."
-
- workflow = Repo.reload(workflow)
-
- assert workflow.lock_version == snapshot.lock_version + 1
- end
- end
-
- describe "run viewer" do
- test "user can toggle their preferred log levels", %{
- conn: conn,
- project: project,
- user: user
- } do
- %{triggers: [trigger], jobs: [job_1 | _rest]} =
- workflow = insert(:simple_workflow, project: project) |> with_snapshot()
-
- workflow = Lightning.Repo.reload(workflow)
-
- snapshot = Lightning.Workflows.Snapshot.get_current_for(workflow)
-
- dataclip = build(:http_request_dataclip, project: project)
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip
- )
-
- run =
- insert(:run,
- work_order: work_order,
- starting_trigger: trigger,
- state: "failed",
- error_type: "CompileError",
- dataclip: dataclip,
- steps: [
- build(:step,
- job: job_1,
- snapshot: snapshot,
- input_dataclip: dataclip,
- exit_reason: "fail",
- error_type: "CompileError",
- started_at: DateTime.utc_now(),
- finished_at: DateTime.utc_now()
- )
- ]
- )
-
- insert(:log_line, run: run)
- insert(:log_line, run: run, step: hd(run.steps))
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{%{a: run.id, m: "expand", s: job_1.id}}",
- on_error: :raise
- )
-
- run_view = find_live_child(view, "run-viewer-#{run.id}")
-
- render_async(run_view)
-
- assert run_view
- |> render()
- |> Floki.parse_fragment!()
- |> Floki.find("span.hero-adjustments-vertical + span")
- |> Floki.text() ==
- "info"
-
- # when the user has not set their preference, we assume they want info
- assert user.preferences["desired_log_level"] |> is_nil()
- log_viewer = run_view |> element("#run-log-#{run.id}")
-
- # info log level is set in the viewer element
- assert log_viewer_selected_level(log_viewer) == "info"
-
- # try choosing another level
- for log_level <- ["debug", "info", "error", "warn"] do
- run_view
- |> element("#run-log-#{run.id}-filter-dropdown-#{log_level}-option")
- |> render_click(%{})
-
- # selected level is set in the viewer
- assert log_viewer_selected_level(log_viewer) == log_level
-
- # the preference is saved with expected levels
- updated_user = Repo.reload(user)
- assert updated_user.preferences["desired_log_level"] == log_level
- end
- end
- end
-
- describe "new manual run" do
- test "gets latest selectable dataclips",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- limit = 4
- search_text = ""
-
- dataclips =
- Enum.map(1..5, fn i ->
- insert(:dataclip,
- body: %{"body-field" => "body-value#{i}"},
- request: %{"headers" => "list"},
- type: :http_request,
- inserted_at: DateTime.add(DateTime.utc_now(), i, :millisecond)
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
- end)
- |> Enum.sort_by(& &1.inserted_at, :desc)
- |> Enum.take(limit)
-
- render_hook(view, "search-selectable-dataclips", %{
- "job_id" => job.id,
- "search_text" => search_text,
- "limit" => limit
- })
-
- assert_reply(view, %{dataclips: ^dataclips})
- end
-
- test "searches for dataclips by uuid",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- dataclip =
- insert(:dataclip,
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :step_result
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=#{Ecto.UUID.generate()}",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: []})
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=#{dataclip.id}",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [^dataclip]})
- end
-
- test "searches for dataclips by uuid prefix",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- dataclip =
- insert(:dataclip,
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :step_result
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=#{Ecto.UUID.generate()}",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: []})
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=#{String.slice(dataclip.id, 0..3)}",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [^dataclip]})
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=#{String.slice(dataclip.id, 0..3)}",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [^dataclip]})
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" =>
- "query=#{String.slice(dataclip.id, 0..1)}&type=step_result",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [^dataclip]})
- end
-
- test "searches for dataclips by type",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- insert(:dataclip,
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :step_result
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- limit = 3
-
- dataclips =
- Enum.map(1..5, fn i ->
- insert(:dataclip,
- body: %{"body-field" => "body-value#{i}"},
- request: %{"headers" => "list"},
- type: :http_request
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
- end)
- |> Enum.sort_by(& &1.inserted_at, :desc)
- |> Enum.take(limit)
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "type=http_request",
- "limit" => limit
- }
- )
-
- assert_reply(view, %{dataclips: ^dataclips})
- end
-
- test "searches for dataclips created after a datetime",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- datetime_param = DateTime.utc_now()
-
- dataclips =
- Enum.map(-1..5, fn i ->
- type = if rem(i, 2) == 0, do: :http_request, else: :step_result
- request = if type == :http_request, do: %{"headers" => "list"}
-
- insert(:dataclip,
- body: %{"body-field" => "body-value#{i}"},
- request: request,
- type: type,
- inserted_at: DateTime.add(datetime_param, i, :minute)
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
- end)
- |> Enum.sort_by(& &1.inserted_at, :desc)
- |> Enum.take(5)
-
- search_text =
- %{
- "after" =>
- DateTime.to_iso8601(datetime_param |> DateTime.add(1, :minute))
- |> String.slice(0..15)
- }
- |> URI.encode_query()
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => search_text,
- "limit" => 10
- }
- )
-
- assert_reply(view, %{dataclips: ^dataclips})
- end
-
- test "searches for dataclips created after a date",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- starting_datetime = ~N[2025-05-15 00:00:00]
-
- dataclips =
- Enum.map(-1..5, fn i ->
- type = if rem(i, 2) == 0, do: :http_request, else: :step_result
- request = if type == :http_request, do: %{"headers" => "list"}
-
- insert(:dataclip,
- body: %{"body-field" => "body-value#{i}"},
- request: request,
- type: type,
- inserted_at: NaiveDateTime.add(starting_datetime, i * 5, :minute)
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
- end)
- |> Enum.drop(1)
- |> Enum.sort_by(& &1.inserted_at, :desc)
-
- search_text =
- %{
- "after" =>
- starting_datetime
- |> NaiveDateTime.to_iso8601()
- |> String.slice(0..15)
- }
- |> URI.encode_query()
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => search_text,
- "limit" => 10
- }
- )
-
- assert_reply(view, %{dataclips: ^dataclips})
- end
-
- test "searches for dataclips from one type created after a date",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- starting_datetime = ~N[2025-05-15 00:00:00]
-
- dataclips =
- Enum.map(-1..5, fn i ->
- type = if rem(i, 2) == 0, do: :http_request, else: :step_result
- request = if type == :http_request, do: %{"headers" => "list"}
-
- insert(:dataclip,
- body: %{"body-field" => "body-value#{i}"},
- request: request,
- type: type,
- inserted_at: NaiveDateTime.add(starting_datetime, i * 5, :minute)
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
- |> then(fn %{body: body, request: request} = dataclip ->
- dataclip
- |> Repo.reload!()
- |> restore_listed(body, request)
- |> then(&%{&1 | body: nil})
- end)
- end)
- |> Enum.drop(1)
- |> Enum.sort_by(& &1.inserted_at, :desc)
- |> Enum.filter(&(&1.type == :step_result))
-
- search_text =
- %{
- "after" =>
- NaiveDateTime.to_iso8601(starting_datetime)
- |> String.slice(0..15),
- "type" => "step_result"
- }
- |> URI.encode_query()
-
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => search_text,
- "limit" => 10
- }
- )
-
- assert_reply(view, %{dataclips: ^dataclips})
- end
-
- test "gets run step and input dataclip",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create a dataclip
- dataclip =
- insert(:dataclip,
- body: %{"input-field" => "input-value"},
- request: %{"headers" => "list"},
- type: :http_request
- )
-
- work_order = insert(:workorder, workflow: workflow, dataclip: dataclip)
-
- # Create run with step in one go using the factory pattern
- run =
- insert(:run,
- workflow: workflow,
- starting_job: job,
- dataclip: dataclip,
- work_order: work_order,
- steps: [
- build(:step, job: job, input_dataclip: dataclip)
- ]
- )
-
- expected_dataclip =
- dataclip
- |> Repo.reload!()
- |> restore_listed(%{"input-field" => "input-value"}, %{
- "headers" => "list"
- })
- |> then(&%{&1 | body: nil})
-
- expected_step_id = hd(run.steps).id
-
- render_hook(view, "get-run-step-and-input-dataclip", %{
- "run_id" => run.id,
- "job_id" => job.id
- })
-
- assert_reply(view, %{
- dataclip: ^expected_dataclip,
- run_step: %{id: ^expected_step_id}
- })
- end
-
- test "returns nil when no dataclip found for run and job",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create a dataclip for the run (required by schema)
- dataclip = insert(:dataclip, body: %{"some" => "data"})
-
- # Create a run with a dataclip but no step for the specific job
- work_order = insert(:workorder, workflow: workflow, dataclip: dataclip)
-
- run =
- insert(:run,
- workflow: workflow,
- starting_job: job,
- dataclip: dataclip,
- work_order: work_order
- )
-
- # Intentionally not creating any step for this job to test the nil case
-
- render_hook(view, "get-run-step-and-input-dataclip", %{
- "run_id" => run.id,
- "job_id" => job.id
- })
-
- assert_reply(view, %{dataclip: nil, run_step: nil})
- end
-
- test "creates run from start job", %{
- conn: conn,
- project: project,
- test: test
- } do
- %{jobs: [job_1, _job_2 | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy",
- on_error: :raise
- )
-
- body = Jason.encode!(%{test: test})
-
- refute Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_1.id)
-
- render_click(view, "manual_run_submit", %{
- manual: %{body: body},
- from_start: true
- })
-
- assert created_run =
- Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_1.id)
-
- assert_redirected(view, ~p"/projects/#{project}/runs/#{created_run}")
- end
-
- test "creates run from specific job", %{
- conn: conn,
- project: project,
- test: test
- } do
- %{jobs: [_job_1, job_2 | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy",
- on_error: :raise
- )
-
- body = Jason.encode!(%{test: test})
-
- refute Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_2.id)
-
- render_click(view, "manual_run_submit", %{
- manual: %{body: body},
- from_job: job_2.id
- })
-
- assert created_run =
- Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_2.id)
-
- assert_redirected(view, ~p"/projects/#{project}/runs/#{created_run}")
- end
-
- test "can rerun",
- %{conn: conn, project: project, user: user} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- # Create a dataclip
- dataclip =
- insert(:dataclip,
- body: %{"input-field" => "input-value"},
- request: %{"headers" => "list"},
- type: :http_request
- )
-
- work_order = insert(:workorder, workflow: workflow, dataclip: dataclip)
-
- # Create run with step in one go using the factory pattern
- run =
- insert(:run,
- workflow: workflow,
- starting_job: job,
- dataclip: dataclip,
- work_order: work_order,
- steps: [
- build(:step, job: job, input_dataclip: dataclip)
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job.id, a: run.id, m: "workflow_input"]}",
- on_error: :raise
- )
-
- render_hook(view, "rerun", %{
- "run_id" => run.id,
- "step_id" => hd(run.steps).id,
- "via" => "job_panel"
- })
-
- created_run = Lightning.Repo.get_by(Lightning.Run, created_by_id: user.id)
-
- assert_redirected(view, ~p"/projects/#{project}/runs/#{created_run}")
- end
-
- test "searches for dataclips by name prefix",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create named dataclips
- %{id: named_dataclip_id} =
- insert(:dataclip,
- name: "My Test Dataclip",
- body: %{"body-field" => "body-value"},
- type: :http_request,
- project: project
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- %{id: other_named_dataclip_id} =
- insert(:dataclip,
- name: "Another Dataclip",
- body: %{"body-field" => "body-value2"},
- type: :http_request,
- project: project
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- # Create dataclip without name
- insert(:dataclip,
- name: nil,
- body: %{"body-field" => "body-value3"},
- type: :http_request,
- project: project
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- # Test searching by name prefix "My"
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=My",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [%{id: ^named_dataclip_id}]})
-
- # Test searching by name prefix "Another"
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=Another",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [%{id: ^other_named_dataclip_id}]})
-
- # Test case insensitive search
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=my",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: [%{id: ^named_dataclip_id}]})
-
- # Test no matches
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "query=nonexistent",
- "limit" => 5
- }
- )
-
- assert_reply(view, %{dataclips: []})
- end
-
- test "searches for dataclips using named_only filter",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create named dataclips
- %{id: named_dataclip1_id} =
- insert(:dataclip,
- name: "First Named",
- body: %{"body-field" => "body-value1"},
- request: %{"headers" => "list"},
- type: :http_request
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- %{id: named_dataclip2_id} =
- insert(:dataclip,
- name: "Second Named",
- body: %{"body-field" => "body-value2"},
- request: %{"headers" => "list"},
- type: :http_request
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- # Create dataclips without names
- insert(:dataclip,
- name: nil,
- body: %{"body-field" => "body-value3"},
- request: %{"headers" => "list"},
- type: :http_request
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- insert(:dataclip,
- name: nil,
- body: %{"body-field" => "body-value4"},
- request: %{"headers" => "list"},
- type: :http_request
- )
- |> tap(&insert(:step, input_dataclip: &1, job: job))
-
- # Test named_only filter
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "named_only=true",
- "limit" => 10
- }
- )
-
- # Should return only named dataclips, ordered by inserted_at desc
- assert_reply(view, %{
- dataclips: [%{id: ^named_dataclip2_id}, %{id: ^named_dataclip1_id}]
- })
-
- # Test without named_only filter - should return all dataclips
- render_hook(
- view,
- "search-selectable-dataclips",
- %{
- "job_id" => job.id,
- "search_text" => "",
- "limit" => 10
- }
- )
-
- # Should return all 4 dataclips
- assert_reply(view, %{dataclips: dataclips})
- assert length(dataclips) == 4
- end
-
- test "update-dataclip-name event fails when user cannot edit workflow",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- # Set up user with viewer permission
- {conn, _user} = setup_project_user(conn, project, :viewer)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create a dataclip
- dataclip =
- insert(:dataclip,
- name: "Original Name",
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :http_request
- )
-
- # Try to update the dataclip name
- render_hook(
- view,
- "update-dataclip-name",
- %{
- "dataclip_id" => dataclip.id,
- "name" => "New Name"
- }
- )
-
- # Should return error message
- assert_reply(view, %{
- error: "You are not authorized to perform this action"
- })
-
- # Verify dataclip name was not changed in database
- updated_dataclip = Lightning.Repo.reload!(dataclip)
- assert updated_dataclip.name == "Original Name"
- end
-
- test "update-dataclip-name event fails when dataclip name is already in use",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create a dataclip
- dataclip =
- insert(:dataclip,
- name: "Original Name",
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :http_request,
- project: project
- )
-
- another_dataclip =
- insert(:dataclip,
- name: "Another Name",
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :http_request,
- project: project
- )
-
- # Try to update the dataclip name
- render_hook(
- view,
- "update-dataclip-name",
- %{
- "dataclip_id" => dataclip.id,
- "name" => another_dataclip.name
- }
- )
-
- # Should return error message
- assert_reply(view, %{
- error: "dataclip name already in use"
- })
-
- # Verify dataclip name was not changed in database
- updated_dataclip = Lightning.Repo.reload!(dataclip)
- assert updated_dataclip.name == "Original Name"
- end
-
- test "update-dataclip-name event updates dataclip name successfully",
- %{conn: conn, project: project} do
- %{jobs: [job | _rest]} =
- workflow = insert(:complex_workflow, project: project)
-
- Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}",
- on_error: :raise
- )
-
- # Create a dataclip
- dataclip =
- insert(:dataclip,
- name: "Original Name",
- body: %{"body-field" => "body-value"},
- request: %{"headers" => "list"},
- type: :http_request
- )
-
- # Update the dataclip name
- assert render_hook(
- view,
- "update-dataclip-name",
- %{
- "dataclip_id" => dataclip.id,
- "name" => "New Name"
- }
- ) =~ "Label created. Dataclip will be saved permanently"
-
- # Should return updated dataclip
- assert_reply(view, %{dataclip: %{name: "New Name"}})
-
- # Verify dataclip name was changed in database
- updated_dataclip = Lightning.Repo.reload!(dataclip)
- assert updated_dataclip.name == "New Name"
-
- audit =
- Lightning.Repo.get_by(Lightning.Auditing.Audit,
- event: "label_created",
- item_id: dataclip.id
- )
-
- assert match?(
- %{
- before: %{"name" => "Original Name"},
- after: %{"name" => "New Name"}
- },
- audit.changes
- )
-
- # clear the dataclip name
- assert render_hook(
- view,
- "update-dataclip-name",
- %{
- "dataclip_id" => dataclip.id,
- "name" => ""
- }
- ) =~
- "Label deleted. Dataclip will be purged when your retention policy limit is reached"
-
- audit =
- Lightning.Repo.get_by(Lightning.Auditing.Audit,
- event: "label_deleted",
- item_id: dataclip.id
- )
-
- assert match?(
- %{
- before: %{"name" => "New Name"},
- after: %{"name" => nil}
- },
- audit.changes
- )
- end
- end
-
- describe "get-current-state event" do
- setup :create_workflow
-
- test "returns workflow params when no run is selected", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_reply(view, %{
- workflow_params: %{},
- run_steps: %{
- start_from: nil,
- steps: [],
- isTrigger: true,
- inserted_at: nil
- },
- run_id: nil,
- history: []
- })
- end
-
- test "returns workflow params with run steps and history when run is selected",
- %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot
- } do
- %{triggers: [trigger], jobs: [job | _]} = workflow
-
- dataclip = insert(:dataclip, project: project, body: %{"test" => "data"})
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- state: :success,
- last_activity: DateTime.utc_now()
- )
-
- started_at = DateTime.utc_now() |> DateTime.add(-60, :second)
- finished_at = DateTime.utc_now() |> DateTime.add(-30, :second)
-
- run =
- insert(:run,
- work_order: work_order,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot,
- state: :success,
- started_at: started_at,
- finished_at: finished_at,
- inserted_at: started_at
- )
-
- insert(:step,
- job: job,
- # Pass as a list
- runs: [run],
- snapshot: snapshot,
- input_dataclip: dataclip,
- started_at: started_at,
- finished_at: finished_at,
- exit_reason: "success",
- error_type: nil
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_reply(view, %{
- workflow_params: _workflow_params,
- run_steps: run_steps,
- run_id: run_id,
- history: history
- })
-
- assert run_id == run.id
- assert run_steps.start_from == trigger.id
- assert run_steps.isTrigger == true
- assert run_steps.inserted_at == started_at
- assert run_steps.run_by == nil
-
- assert length(run_steps.steps) == 1
- [step_data] = run_steps.steps
- assert step_data.job_id == job.id
- assert step_data.error_type == nil
- assert step_data.exit_reason == "success"
- assert step_data.started_at == started_at
- assert step_data.finished_at == finished_at
-
- assert length(history) == 1
- [work_order_data] = history
- assert work_order_data.id == work_order.id
- assert work_order_data.version == snapshot.lock_version
- assert work_order_data.state == :success
- assert work_order_data.last_activity == work_order.last_activity
-
- assert length(work_order_data.runs) == 1
- [run_data] = work_order_data.runs
- assert run_data.id == run.id
- assert run_data.state == :success
- assert run_data.error_type == nil
- assert run_data.started_at == started_at
- assert run_data.finished_at == finished_at
- end
-
- test "returns run steps with created_by user email when present", %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot,
- user: user
- } do
- %{triggers: [trigger], jobs: [job | _]} = workflow
-
- dataclip = insert(:dataclip, project: project, body: %{"test" => "data"})
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip
- )
-
- run =
- insert(:run,
- work_order: work_order,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot,
- created_by: user
- )
-
- insert(:step, job: job, runs: [run], snapshot: snapshot)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_reply(view, %{run_steps: %{run_by: email}})
- assert email == user.email
- end
-
- test "handles job-started runs correctly", %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot
- } do
- %{jobs: [job | _]} = workflow
-
- dataclip = insert(:dataclip, project: project)
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip
- )
-
- run =
- insert(:run,
- work_order: work_order,
- starting_job: job,
- starting_trigger: nil,
- dataclip: dataclip,
- snapshot: snapshot
- )
-
- insert(:step, job: job, runs: [run], snapshot: snapshot)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_reply(view, %{run_steps: run_steps})
- assert run_steps.start_from == job.id
- assert run_steps.isTrigger == false
- end
-
- test "handles runs with multiple steps and error states", %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot
- } do
- %{triggers: [trigger], jobs: [job1, job2 | _]} = workflow
-
- dataclip = insert(:dataclip, project: project)
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- state: :failed
- )
-
- run =
- insert(:run,
- work_order: work_order,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot,
- state: :failed,
- error_type: "RuntimeError"
- )
-
- insert(:step,
- job: job1,
- runs: [run],
- snapshot: snapshot,
- exit_reason: "success",
- error_type: nil
- )
-
- insert(:step,
- job: job2,
- runs: [run],
- snapshot: snapshot,
- exit_reason: "fail",
- error_type: "RuntimeError"
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job1, m: "history"]}",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_reply(view, %{run_steps: %{steps: steps}})
- assert length(steps) == 2
-
- [step1, step2] = steps
- assert step1.exit_reason == "success"
- assert step1.error_type == nil
-
- assert step2.exit_reason == "fail"
- assert step2.error_type == "RuntimeError"
- end
-
- test "returns multiple work orders in history", %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot1,
- user: user
- } do
- %{triggers: [trigger], jobs: [job | _]} = workflow
-
- {:ok, updated_workflow} =
- Workflows.change_workflow(workflow, %{name: "Updated Workflow"})
- |> Workflows.save_workflow(user)
-
- snapshot2 = Lightning.Workflows.Snapshot.get_current_for(updated_workflow)
-
- dataclip = insert(:dataclip, project: project)
-
- work_order1 =
- insert(:workorder,
- workflow: updated_workflow,
- snapshot: snapshot1,
- dataclip: dataclip,
- state: :success
- )
-
- run1 =
- insert(:run,
- work_order: work_order1,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot1,
- state: :success
- )
-
- work_order2 =
- insert(:workorder,
- workflow: updated_workflow,
- snapshot: snapshot2,
- dataclip: dataclip,
- state: :pending
- )
-
- run2 =
- insert(:run,
- work_order: work_order2,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot2,
- state: :started
- )
-
- insert(:step, job: job, runs: [run1], snapshot: snapshot1)
- insert(:step, job: job, runs: [run2], snapshot: snapshot2)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{updated_workflow}/legacy?#{[a: run2, s: job, m: "expand"]}",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_reply(view, %{history: history})
- assert length(history) == 2
-
- versions = Enum.map(history, & &1.version)
- assert snapshot1.lock_version in versions
- assert snapshot2.lock_version in versions
- end
-
- test "canvas is disabled when appropriate", %{
- conn: conn,
- project: project
- } do
- workflow =
- insert(:simple_workflow,
- project: project,
- deleted_at: DateTime.utc_now()
- )
-
- {:ok, _snapshot} = Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy",
- on_error: :raise
- )
-
- render_hook(view, "get-current-state", %{})
-
- assert_push_event(view, "set-disabled", %{disabled: true})
- end
- end
-
- describe "run selection history mode" do
- setup :create_workflow
-
- test "loads historical run data when accessing history mode", %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot
- } do
- %{triggers: [trigger], jobs: [job | _]} = workflow
-
- dataclip = insert(:dataclip, project: project)
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip
- )
-
- run =
- insert(:run,
- work_order: work_order,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot
- )
-
- insert(:step,
- job: job,
- runs: [run],
- snapshot: snapshot,
- exit_reason: "success"
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?m=history&v=#{snapshot.lock_version}&a=#{run.id}&s=#{job.id}"
- )
-
- assert_push_event(view, "patch-runs", %{
- run_id: run_id,
- run_steps: run_steps
- })
-
- assert run_id == run.id
- assert run_steps.start_from == trigger.id
- assert length(run_steps.steps) == 1
- end
-
- test "handles history mode without selected job", %{
- conn: conn,
- project: project,
- workflow: workflow,
- snapshot: snapshot
- } do
- %{triggers: [trigger]} = workflow
-
- dataclip = insert(:dataclip, project: project)
-
- work_order =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip
- )
-
- run =
- insert(:run,
- work_order: work_order,
- starting_trigger: trigger,
- dataclip: dataclip,
- snapshot: snapshot
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?m=history&v=#{snapshot.lock_version}&a=#{run.id}"
- )
-
- expected_run_id = run.id
- assert_push_event(view, "patch-runs", %{run_id: actual_run_id})
- assert actual_run_id == expected_run_id
- end
-
- test "canvas is disabled when workflow is deleted", %{
- conn: conn,
- project: project
- } do
- workflow =
- insert(:simple_workflow,
- project: project,
- deleted_at: DateTime.utc_now()
- )
-
- {:ok, _snapshot} = Lightning.Workflows.Snapshot.create(workflow)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- assert_push_event(view, "set-disabled", %{disabled: true})
- end
- end
-
- defp log_viewer_selected_level(log_viewer) do
- log_viewer
- |> render()
- |> Floki.parse_fragment!()
- |> Floki.attribute("data-log-level")
- |> hd()
- end
-
- defp access_views(
- conn,
- project,
- workflow,
- run,
- job,
- high_priority_user,
- low_priority_user
- ) do
- {:ok, high_priority_view, _html} =
- live(
- log_in_user(conn, high_priority_user),
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}",
- on_error: :raise
- )
-
- {:ok, low_priority_view, _html} =
- live(
- log_in_user(conn, low_priority_user),
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}",
- on_error: :raise
- )
-
- {high_priority_view, low_priority_view}
- end
-
- defp restore_listed(%{type: :http_request} = dataclip, body, request) do
- dataclip
- |> Map.put(:body, %{"data" => body, "request" => request})
- |> Map.put(:request, nil)
- end
-
- defp restore_listed(dataclip, body, _request) do
- dataclip
- |> Map.put(:body, body)
- |> Map.put(:request, nil)
- end
-
- describe "collaborative editor toggle" do
- setup :create_workflow
-
- test "shows collaborative editor toggle when experimental features enabled",
- %{
- conn: conn,
- user: user,
- project: project,
- workflow: workflow
- } do
- # Enable experimental features for the user
- user_with_experimental =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{"experimental_features" => true}
- })
- |> Repo.update!()
-
- {:ok, view, _html} =
- conn
- |> log_in_user(user_with_experimental)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- # Should show the beaker icon toggle
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
- end
-
- test "shows collaborative editor toggle without experimental features",
- %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- # Should show the toggle (no longer gated by experimental features)
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
- end
-
- test "shows collaborative editor toggle on non-latest snapshots", %{
- conn: conn,
- user: user,
- project: project,
- workflow: workflow,
- snapshot: snapshot
- } do
- # Create a new snapshot to make the original non-latest
- job_attrs =
- workflow.jobs |> Enum.map(&%{id: &1.id, name: &1.name <> " updated"})
-
- {:ok, _updated_workflow} =
- Workflows.change_workflow(workflow, %{jobs: job_attrs})
- |> Workflows.save_workflow(user)
-
- {:ok, view, _html} =
- conn
- |> live(
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?v=#{snapshot.lock_version}"
- )
-
- # Toggle is shown even on non-latest snapshots (no longer conditionally hidden)
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
- end
-
- test "shows collaborative editor toggle only on latest snapshots with experimental features",
- %{
- conn: conn,
- user: user,
- project: project,
- workflow: workflow
- } do
- # Enable experimental features
- user_with_experimental =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{"experimental_features" => true}
- })
- |> Repo.update!()
-
- {:ok, view, _html} =
- conn
- |> log_in_user(user_with_experimental)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- # Should show toggle on latest version
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
- end
-
- test "navigates to collaborative editor when toggle clicked and saves preference",
- %{
- conn: conn,
- user: user,
- project: project,
- workflow: workflow
- } do
- # Enable experimental features
- user_with_experimental =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{"experimental_features" => true}
- })
- |> Repo.update!()
-
- {:ok, view, _html} =
- conn
- |> log_in_user(user_with_experimental)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- # Click the collaborative editor toggle
- view
- |> element("button[id*='deprecated']")
- |> render_click()
-
- # Should navigate to collaborative editor route
- assert_redirect(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}"
- )
-
- # Verify preference was saved
- updated_user = Lightning.Repo.reload(user_with_experimental)
- assert updated_user.preferences["prefer_legacy_editor"] == false
- end
-
- test "toggle has correct styling and accessibility", %{
- conn: conn,
- user: user,
- project: project,
- workflow: workflow
- } do
- # Enable experimental features
- user_with_experimental =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{"experimental_features" => true}
- })
- |> Repo.update!()
-
- {:ok, view, _html} =
- conn
- |> log_in_user(user_with_experimental)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- toggle_element =
- view
- |> element("button[id*='deprecated']")
-
- toggle_html = render(toggle_element)
-
- # Check accessibility (aria-label)
- assert toggle_html =~ "Click to switch"
- end
-
- test "preserves existing experimental features preferences", %{
- conn: conn,
- user: user,
- project: project,
- workflow: workflow
- } do
- # Set up user with experimental features and other preferences
- user_with_prefs =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{
- "experimental_features" => true,
- "existing_pref" => "value",
- "another_setting" => false
- }
- })
- |> Repo.update!()
-
- {:ok, view, _html} =
- conn
- |> log_in_user(user_with_prefs)
- |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy")
-
- # Should show toggle
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
-
- # Verify all preferences are preserved
- updated_user = Repo.reload(user_with_prefs)
- assert updated_user.preferences["experimental_features"] == true
- assert updated_user.preferences["existing_pref"] == "value"
- assert updated_user.preferences["another_setting"] == false
- end
-
- test "shows collaborative editor toggle when creating new workflow with experimental features",
- %{
- conn: conn,
- user: user,
- project: project
- } do
- # Enable experimental features
- user_with_experimental =
- user
- |> Ecto.Changeset.change(%{
- preferences: %{"experimental_features" => true}
- })
- |> Repo.update!()
-
- {:ok, view, _html} =
- conn
- |> log_in_user(user_with_experimental)
- |> live(~p"/projects/#{project.id}/w/new/legacy")
-
- # Should show the beaker icon toggle even on new workflow page
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
- end
-
- test "shows collaborative editor toggle when creating new workflow",
- %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Should show the toggle (no longer gated by experimental features)
- assert has_element?(
- view,
- "button[id*='deprecated']"
- )
- end
-
- test "shows collaborative editor toggle in job inspector with experimental features",
- %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- # Enable experimental features for user
- user
- |> Ecto.Changeset.change(%{
- preferences: %{"experimental_features" => true}
- })
- |> Lightning.Repo.update!()
-
- job = insert(:job, workflow: workflow, name: "test-job")
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- assert html =~ "Click to switch"
- end
-
- test "shows collaborative editor toggle in job inspector without experimental features",
- %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: _user
- } do
- job = insert(:job, workflow: workflow, name: "test-job")
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- # Should show beaker icon in job inspector (no longer gated by experimental features)
- assert html =~ "Click to switch"
- end
- end
-
- describe "sandbox indicator banner" do
- test "shows banner when viewing workflow in sandbox project", %{conn: conn} do
- user = insert(:user)
- parent_project = insert(:project, name: "Production Project")
-
- sandbox =
- insert(:sandbox,
- parent: parent_project,
- name: "test-sandbox",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: sandbox.id)
- job = insert(:job, workflow: workflow, name: "test-job")
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{sandbox.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- # Banner only shows in inspector, not on canvas
- assert html =~ "You are currently working in the sandbox"
- assert html =~ sandbox.name
- # No "Switch to parent project" link per Joe's feedback
- refute html =~ "Switch to Production Project"
- end
-
- test "does not show banner when viewing workflow in root project", %{
- conn: conn
- } do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Production Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?v=#{workflow.lock_version}"
- )
-
- refute html =~ "You are currently working in the sandbox"
- # Root projects should not show sandbox-related switch links
- refute html =~ "working in the sandbox"
- end
-
- test "shows correct root project in deeply nested sandbox", %{conn: conn} do
- user = insert(:user)
- root_project = insert(:project, name: "Root Project")
-
- sandbox_a =
- insert(:sandbox,
- parent: root_project,
- name: "sandbox-a",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- sandbox_b =
- insert(:sandbox,
- parent: sandbox_a,
- name: "sandbox-b",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: sandbox_b.id)
- job = insert(:job, workflow: workflow, name: "test-job")
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{sandbox_b.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- # Banner shows in inspector with current sandbox name
- assert html =~ "You are currently working in the sandbox"
- assert html =~ sandbox_b.name
- # No "Switch to parent project" link per Joe's feedback
- refute html =~ "Switch to Root Project"
- end
-
- test "shows banner in job inspector when editing job in sandbox", %{
- conn: conn
- } do
- user = insert(:user)
- parent_project = insert(:project, name: "Production Project")
-
- sandbox =
- insert(:sandbox,
- parent: parent_project,
- name: "test-sandbox",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: sandbox.id)
- job = insert(:job, workflow: workflow, name: "test-job")
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{sandbox.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- assert html =~ "You are currently working in the sandbox"
- assert html =~ sandbox.name
- # No "Switch to parent project" link per Joe's feedback
- refute html =~ "Switch to Production Project"
- end
-
- test "does not show banner in job inspector when editing job in root project",
- %{conn: conn} do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Production Project",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
- job = insert(:job, workflow: workflow, name: "test-job")
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- refute html =~ "You are currently working in the sandbox"
- # No sandbox banner should appear in root project
- refute html =~ "working in the sandbox"
- end
-
- test "shows env chip on canvas when project has env", %{conn: conn} do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Production Project",
- env: "production",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?v=#{workflow.lock_version}"
- )
-
- assert html =~ "canvas-project-env"
- assert html =~ "production"
- assert html =~ "Project environment is production"
- end
-
- test "shows env chip in inspector when project has env", %{conn: conn} do
- user = insert(:user)
-
- project =
- insert(:project,
- name: "Production Project",
- env: "staging",
- project_users: [%{user_id: user.id, role: :owner}]
- )
-
- workflow = workflow_fixture(project_id: project.id)
- job = insert(:job, workflow: workflow, name: "test-job")
-
- conn = log_in_user(conn, user)
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}"
- )
-
- assert html =~ "inspector-project-env"
- assert html =~ "staging"
- assert html =~ "Project environment is staging"
- end
- end
-
- defp stub_apollo_unavailable(_context) do
- stub(Lightning.MockConfig, :apollo, fn key ->
- case key do
- :endpoint -> "http://localhost:3000"
- :ai_assistant_api_key -> "test_api_key"
- end
- end)
-
- stub(Lightning.Tesla.Mock, :call, fn %{method: :get}, _opts ->
- {:error, :econnrefused}
- end)
-
- :ok
- end
-end
diff --git a/test/lightning_web/live/workflow_live/editor_test.exs b/test/lightning_web/live/workflow_live/editor_test.exs
deleted file mode 100644
index 0fdcea1d47b..00000000000
--- a/test/lightning_web/live/workflow_live/editor_test.exs
+++ /dev/null
@@ -1,2003 +0,0 @@
-defmodule LightningWeb.WorkflowLive.EditorTest do
- use LightningWeb.ConnCase, async: true
-
- import ExUnit.CaptureLog
- import Phoenix.LiveViewTest
- import Lightning.WorkflowLive.Helpers
- import Lightning.Factories
-
- import Ecto.Query
-
- alias Lightning.Auditing.Audit
- alias Lightning.Invocation
- alias Lightning.Workflows.Workflow
-
- setup :register_and_log_in_user
- setup :create_project_for_current_user
- setup :create_workflow
-
- test "can edit a jobs body", %{
- project: project,
- workflow: workflow,
- conn: conn
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}"
- )
-
- job = workflow.jobs |> List.first()
-
- view |> select_node(job, workflow.lock_version)
-
- view |> job_panel_element(job)
-
- assert view |> job_panel_element(job) |> render() =~ "First Job",
- "can see the job name in the panel"
-
- view |> click_edit(job)
-
- assert view |> job_edit_view(job) |> has_element?(),
- "can see the job_edit_view component"
- end
-
- test "mounts the JobEditor with the correct attrs", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- project_credential =
- insert(:project_credential,
- project: project,
- credential:
- build(:credential,
- name: "dummytestcred",
- schema: "http",
- body: %{
- username: "test",
- password: "test"
- }
- )
- )
-
- job = workflow.jobs |> hd()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}"
- )
-
- {actual_attrs, inner_json} =
- view
- |> job_editor()
- |> get_attrs_and_inner_html()
- |> then(fn {attrs, inner_html} ->
- {attrs, inner_html |> Jason.decode!()}
- end)
-
- # The JobEditor component should be mounted with a resolved version number
- assert job.adaptor == "@openfn/language-common@latest"
-
- assert {"type", "application/json"} in actual_attrs
- assert {"id", "JobEditor-1"} in actual_attrs
- assert {"phx-hook", "HeexReactComponent"} in actual_attrs
- assert {"data-react-name", "JobEditor"} in actual_attrs
-
- assert inner_json["adaptor"] == "@openfn/language-common@1.6.2"
- assert inner_json["source"] == job.body
- assert inner_json["job_id"] == job.id
- assert inner_json["disabled"] == "false"
-
- # try changing the assigned credential
-
- credential_block =
- element(view, "#modal-header-credential-block") |> render()
-
- assert credential_block =~ "No Credential"
- refute credential_block =~ project_credential.credential.name
-
- # This is a hack to change the project_credential_id while the inspector
- # is open. (The workflow-form is not rendered when the inspector is open)
- view
- |> render_hook("validate", %{
- workflow: %{
- jobs: %{"0" => %{"project_credential_id" => project_credential.id}}
- }
- })
-
- credential_block =
- element(view, "#modal-header-credential-block") |> render()
-
- refute credential_block =~ "No Credential"
- assert credential_block =~ project_credential.credential.name
- end
-
- describe "manual runs" do
- @tag role: :viewer
- test "viewers can't run a job", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- job = workflow.jobs |> hd()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}"
- )
-
- # dataclip dropdown is disabled
- # TODO: test this in the react component
- # assert view
- # |> element(
- # ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'][disabled]}
- # )
- # |> has_element?()
-
- # assert view
- # |> element(
- # ~s{button[type='submit'][form='manual_run_form'][disabled]}
- # )
- # |> has_element?()
-
- # Check that the liveview can handle an empty submit (dataclip dropdown is disabled)
- # which happens on socket reconnects.
- view |> render_change("manual_run_change")
-
- assert view |> render_click("manual_run_submit", %{"manual" => %{}}) =~
- "You are not authorized to perform this action."
- end
-
- @tag skip: "component moved to react"
- test "can see the last 3 dataclips", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- job = workflow.jobs |> hd()
-
- dataclip_ids =
- insert_list(4, :step,
- job: job,
- inserted_at: fn ->
- ExMachina.sequence(:past_timestamp, fn i ->
- DateTime.utc_now() |> DateTime.add(-i)
- end)
- end
- )
- |> Enum.map(fn step ->
- step.input_dataclip_id
- end)
- |> Enum.reverse()
-
- # wiped dataclip. This is the latest dataclip
- wiped_dataclip = insert(:dataclip, body: nil, wiped_at: DateTime.utc_now())
-
- insert(:step, job: job, input_dataclip: wiped_dataclip)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}"
- )
-
- assert view
- |> element(
- ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'] option},
- "Create a new input"
- )
- |> has_element?()
-
- for dataclip_id <- dataclip_ids |> Enum.slice(0..2) do
- assert view
- |> element(
- ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'] option},
- dataclip_id
- )
- |> has_element?()
- end
-
- # wiped dataclip is not listed despite being latest
- refute view
- |> element(
- ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'] option},
- wiped_dataclip.id
- )
- |> has_element?()
- end
-
- test "can create a new input dataclip", %{
- conn: conn,
- project: p,
- workflow: w
- } do
- job = w.jobs |> hd
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}"
- )
-
- assert Invocation.list_dataclips_for_job(job) |> Enum.count() == 0
-
- body = %{"a" => 1}
-
- render_submit(view, "manual_run_submit",
- manual: %{
- body: Jason.encode!(body)
- }
- )
-
- assert where(
- Lightning.Invocation.Dataclip,
- [d],
- d.body == ^body and d.type == :saved_input and
- d.project_id == ^p.id
- )
- |> Lightning.Repo.exists?()
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag role: :editor, skip: "component moved to react"
- test "can't with a new dataclip if it's invalid", %{
- conn: conn,
- project: p,
- workflow: w
- } do
- job = w.jobs |> hd
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}"
- )
-
- view
- |> form("#manual-job-#{job.id} form", %{
- "manual" => %{"body" => "1"}
- })
- |> render_change()
-
- assert view
- |> has_element?("#manual-job-#{job.id} form", "Must be an object")
-
- view
- |> form("#manual-job-#{job.id} form", %{
- "manual" => %{"body" => "]"}
- })
- |> render_change()
-
- assert view |> has_element?("#manual-job-#{job.id} form", "Invalid JSON")
- end
-
- test "can't run if limit is exceeded", %{
- conn: conn,
- project: %{id: project_id},
- workflow: w
- } do
- job = w.jobs |> hd
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project_id}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}"
- )
-
- assert Invocation.list_dataclips_for_job(job) |> Enum.count() == 0
-
- Mox.stub(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- &Lightning.Extensions.StubUsageLimiter.limit_action/2
- )
-
- assert view
- |> render_submit("manual_run_submit",
- manual: %{
- body: Jason.encode!(%{"a" => 1})
- }
- )
- |> Floki.parse_fragment!()
-
- assert view
- |> has_element?("[data-flash-kind='error']", "Runs limit exceeded")
- end
-
- test "can run a job", %{conn: conn, project: p, workflow: w, user: user} do
- job = w.jobs |> hd
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}"
- )
-
- assert view
- |> element(
- "button[type='submit'][form='manual_run_form'][disabled]"
- )
- |> has_element?()
-
- render_change(view, "manual_run_change", %{
- "manual" => %{"body" => "{}"}
- })
-
- refute view
- |> element(
- "button[type='submit'][form='manual_run_form'][disabled]"
- )
- |> has_element?()
-
- assert [] == live_children(view)
-
- render_submit(view, "manual_run_submit", %{})
-
- assert [run_viewer] = live_children(view)
- render_async(run_viewer)
-
- assert run_viewer
- |> element("li:nth-child(6) dd", "Enqueued")
- |> has_element?()
-
- # Check that manually triggered run shows the user's email as the starter
- assert render(run_viewer) =~ user.email,
- "shows user email as starter for manually triggered run"
- end
-
- @tag skip: "component moved to react"
- test "the new dataclip is selected after running job", %{
- conn: conn,
- project: p,
- workflow: w
- } do
- job = w.jobs |> hd
-
- existing_dataclip = insert(:dataclip, project: p)
-
- insert(:workorder,
- workflow: w,
- dataclip: existing_dataclip,
- runs: [
- build(:run,
- dataclip: existing_dataclip,
- starting_job: job,
- steps: [build(:step, job: job, input_dataclip: existing_dataclip)]
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}"
- )
-
- body = %{"val" => Ecto.UUID.generate()}
-
- dataclip_query =
- where(
- Lightning.Invocation.Dataclip,
- [d],
- d.type == :saved_input and
- d.project_id == ^p.id
- )
-
- refute Lightning.Repo.exists?(dataclip_query)
- refute render(view) =~ body["val"]
-
- view
- |> form("#manual-job-#{job.id} form", %{
- manual: %{body: Jason.encode!(body)}
- })
- |> render_submit()
-
- new_dataclip = Lightning.Repo.one(dataclip_query)
-
- assert view
- |> dataclip_viewer("selected-dataclip-#{new_dataclip.id}")
- |> has_element?()
-
- element =
- view
- |> element(
- "select[name='manual[dataclip_id]'] option[value='#{new_dataclip.id}']"
- )
-
- assert render(element) =~ "selected"
-
- refute view
- |> element("save-and-run", "Run")
- |> has_element?()
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- test "creating a work order from a newly created job should save the workflow first",
- %{
- conn: conn,
- project: project
- } do
- workflow =
- insert(:workflow, project: project)
- |> Lightning.Repo.preload([:jobs, :work_orders])
- |> with_snapshot()
-
- new_job_name = "new job"
-
- assert workflow.jobs |> Enum.count() === 0
-
- assert workflow.jobs |> Enum.find(fn job -> job.name === new_job_name end) ===
- nil
-
- assert workflow.work_orders |> Enum.count() === 0
-
- %{"value" => %{"id" => job_id}} =
- job_patch = add_job_patch(new_job_name)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # add a job to it but don't save
- view |> push_patches_to_view([job_patch])
-
- view |> select_node(%{id: job_id}, workflow.lock_version)
-
- view |> click_edit(%{id: job_id})
-
- view |> change_editor_text("some body")
-
- render_submit(view, "manual_run_submit", %{
- manual: %{body: Jason.encode!(%{})}
- })
-
- assert_patch(view)
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
-
- workflow =
- Lightning.Repo.get!(Workflow, workflow.id)
- |> Lightning.Repo.preload([:jobs, :work_orders])
-
- assert workflow.jobs |> Enum.count() === 1
-
- assert workflow.jobs
- |> Enum.find(fn job -> job.name === new_job_name end)
- |> Map.get(:name) === new_job_name
-
- assert workflow.work_orders |> Enum.count() === 1
- end
-
- test "creating a workorder from a newly created workflow and job saves the workflow first",
- %{
- conn: conn,
- user: user
- } do
- Mox.verify_on_exit!()
-
- project =
- insert(:project, project_users: [%{user_id: user.id, role: :admin}])
-
- workflow_name = "mytest workflow"
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/new/legacy", on_error: :raise)
-
- {view, parsed_workflow} = select_template(view, "base-webhook-template")
-
- view |> render_click("save")
-
- workflow =
- Lightning.Repo.get_by(Lightning.Workflows.Workflow,
- project_id: project.id
- )
-
- expected_workflow_name = parsed_workflow["name"] || "Event-based Workflow"
- assert workflow.name == expected_workflow_name
- initial_workflow_id = workflow.id
-
- view
- |> form("#workflow-form")
- |> render_change(workflow: %{name: workflow_name})
-
- job_data = List.first(parsed_workflow["jobs"])
- job_id = job_data["id"]
- original_job_name = job_data["name"]
-
- view |> select_node(%{id: job_id})
-
- view |> click_edit(%{id: job_id})
-
- new_job_body = "#{job_data["body"] || "default body"}"
- view |> change_editor_text(new_job_body)
-
- workflow =
- Lightning.Repo.get(Lightning.Workflows.Workflow, initial_workflow_id)
- |> Lightning.Repo.preload(:work_orders)
-
- assert length(workflow.work_orders) == 0
-
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- fn %{type: :new_run}, _context -> :ok end
- )
-
- Lightning.Workflows.subscribe(project.id)
-
- render_submit(view, "manual_run_submit", %{
- manual: %{body: Jason.encode!(%{})}
- })
-
- assert_patch(view)
-
- live_children(view) |> Enum.each(&render_async/1)
-
- workflow =
- Lightning.Repo.get(Lightning.Workflows.Workflow, initial_workflow_id)
- |> Lightning.Repo.preload([:jobs, :work_orders])
-
- assert workflow.name == workflow_name
-
- assert job = Enum.find(workflow.jobs, &(&1.id == job_id))
- assert job.name == original_job_name
- assert job.body == new_job_body
-
- assert length(workflow.work_orders) == 1
-
- assert_received %Lightning.Workflows.Events.WorkflowUpdated{
- workflow: %{id: ^initial_workflow_id}
- }
- end
-
- test "retry a work order saves the workflow first", %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _], triggers: [trigger]} = workflow,
- snapshot: snapshot
- } do
- Mox.verify_on_exit!()
-
- dataclip = insert(:dataclip, type: :http_request)
-
- # disable the trigger
- trigger
- |> Ecto.Changeset.change(%{enabled: false})
- |> Lightning.Repo.update!()
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- state: :failed,
- runs: [
- build(:run,
- dataclip: dataclip,
- snapshot: snapshot,
- starting_job: job_1,
- state: :failed,
- steps: [
- build(:step,
- job: job_1,
- snapshot: snapshot,
- input_dataclip: dataclip,
- output_dataclip: build(:dataclip),
- exit_reason: "fail",
- started_at: build(:timestamp),
- finished_at: build(:timestamp)
- )
- ]
- )
- ]
- )
-
- assert job_1.body === "fn(state => { return {...state, extra: \"data\"} })"
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view
- |> change_editor_text("fn(state => state)")
-
- view
- |> render_click("validate", %{
- "workflow" => %{"triggers" => %{"0" => %{"enabled" => true}}}
- })
-
- # Try retrying with an error from the limitter
- error_msg = "Oopsie Doopsie! An error occured"
-
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- 2,
- fn
- %{type: :new_run}, _context ->
- :ok
-
- %{type: :activate_workflow}, _context ->
- {:error, :too_many_workflows, %{text: error_msg}}
- end
- )
-
- html =
- view
- |> element("#save-and-run", "Run (Retry)")
- |> render_click()
-
- assert html =~ error_msg
-
- # Retry with an ok from the limitter
- Mox.expect(
- Lightning.Extensions.MockUsageLimiter,
- :limit_action,
- 2,
- fn
- %{type: :new_run}, _context ->
- :ok
-
- %{type: :activate_workflow}, _context ->
- :ok
- end
- )
-
- view
- |> element("#save-and-run", "Run (Retry)")
- |> render_click()
-
- assert_patch(view)
-
- workflow =
- Lightning.Repo.reload(workflow) |> Lightning.Repo.preload([:jobs])
-
- job_1 = workflow.jobs |> Enum.find(fn job -> job.id === job_1.id end)
- assert job_1.body !== "fn(state => { return {...state, extra: \"data\"} })"
- assert job_1.body === "fn(state => state)"
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag skip: "component moved to react"
- test "selects the input dataclip for the step if a run is followed",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1, job_2 | _rest]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip = insert(:dataclip, project: project, type: :http_request)
-
- output_dataclip =
- insert(:dataclip,
- project: project,
- type: :step_result,
- body: %{"val" => Ecto.UUID.generate()}
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_job: job_1,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_1,
- input_dataclip: input_dataclip,
- output_dataclip: output_dataclip,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- ),
- build(:step,
- snapshot: snapshot,
- job: job_2,
- input_dataclip: output_dataclip,
- output_dataclip:
- build(:dataclip,
- type: :step_result,
- body: %{}
- ),
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- # insert 3 new dataclips
- dataclips = insert_list(3, :dataclip, project: project)
-
- # associate dataclips with job 2
- for dataclip <- dataclips do
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: dataclip,
- starting_job: job_2,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_2,
- input_dataclip: dataclip,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: nil,
- exit_reason: nil
- )
- ]
- )
- ]
- )
- end
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # the step dataclip is different from the run dataclip.
- # this assertion means that the run dataclip won't be selected
- refute run.dataclip_id == output_dataclip.id
-
- # the form has the dataclips body
- assert view
- |> dataclip_viewer("selected-dataclip-#{output_dataclip.id}")
- |> has_element?()
-
- # the step dataclip is selected
- element =
- view
- |> element(
- "select[name='manual[dataclip_id]'] option[value='#{output_dataclip.id}']"
- )
-
- assert render(element) =~ "selected"
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- [run_viewer_live] = live_children(view)
- render_async(run_viewer_live)
- render_async(run_viewer_live)
- end
-
- @tag skip: "component moved to react"
- test "selects the input dataclip for the run if no step has been added yet",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _rest]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip = insert(:dataclip, project: project, type: :http_request)
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_job: job_1,
- steps: []
- )
- ]
- )
-
- # insert 3 new dataclips
- dataclips = insert_list(3, :dataclip, project: project)
-
- # associate dataclips with job 1
- for dataclip <- dataclips do
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: dataclip,
- starting_job: job_1,
- steps: [
- build(:step,
- job: job_1,
- snapshot: snapshot,
- input_dataclip: dataclip,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: nil,
- exit_reason: nil
- )
- ]
- )
- ]
- )
- end
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # the form has the dataclip
- assert element(view, "#manual-job-#{job_1.id} form") |> render() =~
- input_dataclip.id
-
- # the run dataclip is selected
- element =
- view
- |> element(
- "select[name='manual[dataclip_id]'] option[value='#{input_dataclip.id}']"
- )
-
- assert render(element) =~ "selected"
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag skip: "component moved to react"
- test "shows the body of selected dataclip correctly after retrying a workorder from a non-first step",
- %{
- conn: conn,
- project: project,
- workflow:
- %{jobs: [job_1, job_2 | _rest], triggers: [trigger]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip = insert(:dataclip, project: project, type: :http_request)
-
- output_dataclip =
- insert(:dataclip,
- project: project,
- type: :step_result,
- body: %{"uuid" => Ecto.UUID.generate()}
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- state: :failed,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_trigger: trigger,
- state: :failed,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_1,
- input_dataclip: input_dataclip,
- output_dataclip: output_dataclip,
- exit_reason: "success",
- started_at: build(:timestamp),
- finished_at: build(:timestamp)
- ),
- build(:step,
- snapshot: snapshot,
- job: job_2,
- input_dataclip: output_dataclip,
- output_dataclip: build(:dataclip),
- exit_reason: "fail",
- started_at: build(:timestamp),
- finished_at: build(:timestamp)
- )
- ]
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
-
- # retry workorder
- view
- |> element("#save-and-run", "Run (Retry)")
- |> render_click()
-
- path = assert_patch(view)
-
- {:ok, view, _html} = live(conn, path, on_error: :raise)
-
- # the run input dataclip is selected
- element =
- view
- |> element(
- "select[name='manual[dataclip_id]'] option[value='#{output_dataclip.id}']"
- )
-
- assert render(element) =~ "selected"
-
- # the body is rendered correctly
- form = "#manual-job-#{job_2.id} form"
-
- refute view |> element(form) |> render() =~
- "Input data for this step has not been retained"
-
- assert view
- |> dataclip_viewer("selected-dataclip-#{output_dataclip.id}")
- |> has_element?()
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag skip: "component moved to react"
- test "does not show the dataclip select input if the step dataclip is not available",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _rest], triggers: [trigger]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip = insert(:dataclip, project: project, type: :http_request)
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_trigger: trigger,
- steps: [
- build(:step,
- job: job_1,
- snapshot: snapshot,
- input_dataclip: nil,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # notice that we haven't wiped the run dataclip.
- # This is intentional to assert that we dont EVER fallback to the run dataclip
- # if we dont find a dataclip on the step
- assert is_nil(input_dataclip.wiped_at)
-
- # the form does not contain the dataclip
- form = element(view, "#manual-job-#{job_1.id} form")
- refute render(form) =~ input_dataclip.id
-
- # the select input doesn't exist
- refute has_element?(view, "select#manual_run_form_dataclip_id")
-
- assert render(form) =~ "data for this step has not been retained"
-
- # user can click to show the dataclip selector
- assert has_element?(view, "#toggle_dataclip_selector_button")
-
- view |> element("#toggle_dataclip_selector_button") |> render_click()
-
- # the select input now exists
- assert has_element?(view, "select[name='manual[dataclip_id]']")
-
- # the wiped message is no longer displayed
- refute render(form) =~ "data for this step has not been retained"
-
- assert has_element?(view, "textarea[name='manual[body]']")
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag skip: "component moved to react"
- test "shows the wiped dataclip viewer if the step dataclip was wiped",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _rest]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip =
- insert(:dataclip,
- project: project,
- type: :saved_input,
- wiped_at: DateTime.utc_now(),
- body: nil
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_job: job_1,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_1,
- input_dataclip: input_dataclip,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # the form contains the dataclip
- form = element(view, "#manual-job-#{job_1.id} form")
- assert render(form) =~ input_dataclip.id
-
- # the select input exists
- assert has_element?(view, "select[name='manual[dataclip_id]']")
-
- # the body says that it was wiped
- assert render(form) =~ "data for this step has not been retained"
-
- refute has_element?(view, "textarea[name='manual[body]']"),
- "dataclip body input is missing"
-
- # lets select the create new dataclip option
- form |> render_change(manual: %{dataclip_id: nil})
-
- # the dataclip textarea input now exists
- assert has_element?(view, "textarea[name='manual[body]']"),
- "dataclip body input exists"
-
- # the wiped message is no longer displayed
- refute render(form) =~ "data for this step has not been retained"
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag skip: "component moved to react"
- test "shows the missing dataclip viewer if the selected step wasn't executed in the run",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1, job_2 | _rest]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip =
- insert(:dataclip,
- project: project,
- type: :saved_input,
- wiped_at: DateTime.utc_now(),
- body: %{}
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_job: job_1,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_1,
- input_dataclip: input_dataclip,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # the form exists
- form = element(view, "#manual-job-#{job_2.id} form")
- assert has_element?(form)
-
- # the select input is not present
- refute has_element?(view, "select[name='manual[dataclip_id]']")
- # the textarea doesn not exist
- refute has_element?(view, "textarea[name='manual[body]']")
-
- # the body says that the step wasn't run
- assert render(form) =~ "This job was not/is not yet included in this Run"
-
- # the body does not say that it was wiped
- refute render(form) =~ "data for this step has not been retained"
-
- refute has_element?(view, "textarea[name='manual[body]']"),
- "dataclip body input is missing"
-
- # lets click the button to show the editor
- view |> element("#toggle_dataclip_selector_button") |> render_click()
-
- # the dataclip textarea input now exists
- assert has_element?(view, "textarea[name='manual[body]']"),
- "dataclip body input exists"
-
- # the job not run message is no longer displayed
- refute render(form) =~ "This job was not/is not yet included in this Run"
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- test "users can retry a workorder from a followed run",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [_job_1, job_2 | _rest]} = workflow,
- snapshot: snapshot
- } do
- {dataclips, %{runs: [run]} = workorder} =
- rerun_setup(project, workflow, snapshot)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # user gets option to rerun
- assert has_element?(view, "button", "Run (Retry)")
- assert has_element?(view, "button", "Run (New Work Order)")
-
- # if we choose a different dataclip, the retry button disappears
- render_change(view, "manual_run_change",
- manual: %{dataclip_id: hd(dataclips).id}
- )
-
- refute has_element?(view, "button", "Run (Retry)")
- assert has_element?(view, "button", "Run")
-
- # if we choose the step input dataclip, the retry button becomes available
- step = Enum.find(run.steps, fn step -> step.job_id == job_2.id end)
-
- render_change(view, "manual_run_change",
- manual: %{dataclip_id: step.input_dataclip_id}
- )
-
- assert has_element?(view, "button", "Run (Retry)")
- assert has_element?(view, "button", "Run (New Work Order)")
-
- view |> element("button", "Run (Retry)") |> render_click()
-
- all_runs =
- Lightning.Repo.preload(workorder, [:runs], force: true).runs
-
- assert Enum.count(all_runs) == 2
-
- [new_run] =
- Enum.reject(all_runs, fn a -> a.id == run.id end)
-
- html = render(view)
-
- # refute html =~ run.id
- assert html =~ new_run.id
- end
-
- test "can't retry when limit has been reached",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [_job_1, job_2 | _rest]} = workflow,
- snapshot: snapshot
- } do
- {_dataclips, %{runs: [run]} = workorder} =
- rerun_setup(project, workflow, snapshot)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # user gets option to rerun
- assert has_element?(view, "button", "Run (Retry)")
- assert has_element?(view, "button", "Run (New Work Order)")
-
- view |> element("button", "Run (Retry)") |> render_click()
-
- all_runs =
- Lightning.Repo.preload(workorder, [:runs], force: true).runs
-
- assert Enum.count(all_runs) == 2
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- test "can't retry when workflow has been deleted",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [_job_1, job_2 | _rest]} = workflow,
- snapshot: snapshot
- } do
- {_dataclips, %{runs: [run]} = _workorder} =
- rerun_setup(project, workflow, snapshot)
-
- workflow
- |> Ecto.Changeset.change(%{
- deleted_at: DateTime.utc_now() |> DateTime.truncate(:second)
- })
- |> Lightning.Repo.update!()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- live_children(view) |> Enum.each(&render_async/1)
-
- # user gets no option to rerun
- assert has_element?(view, "button[disabled='disabled']", "Run (Retry)")
-
- assert has_element?(
- view,
- "button[disabled='disabled']",
- "Run"
- )
-
- # submit event regardless
- step = Enum.find(run.steps, fn step -> step.job_id == job_2.id end)
-
- assert render_click(view, "rerun", %{run_id: run.id, step_id: step.id}) =~
- "Cannot rerun a deleted a workflow"
- end
-
- test "followed run with wiped dataclip renders the page correctly",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1, job_2 | _rest]} = workflow,
- snapshot: snapshot
- } do
- wiped_dataclip =
- insert(:dataclip,
- project: project,
- type: :http_request,
- body: nil,
- wiped_at: DateTime.utc_now()
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: wiped_dataclip,
- state: :success,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: wiped_dataclip,
- starting_job: job_1,
- state: :success,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_1,
- input_dataclip: nil,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- ),
- build(:step,
- snapshot: snapshot,
- job: job_2,
- input_dataclip: nil,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # user cannot rerun
- refute has_element?(view, "button", "Run (Retry)")
-
- assert has_element?(view, "button:disabled", "Run"),
- "create new workorder button is disabled"
-
- # Wait out all the async renders on RunViewerLive, avoiding Postgrex client
- # disconnection warnings.
- live_children(view) |> Enum.each(&render_async/1)
- end
-
- @tag skip: "component moved to react"
- test "selected dataclip viewer is updated correctly if dataclip is wiped",
- %{
- conn: conn,
- project: project,
- snapshot: snapshot,
- workflow: %{jobs: [job_1, _job_2 | _rest]} = workflow
- } do
- unique_val = "random" <> Ecto.UUID.generate()
-
- input_dataclip =
- insert(:dataclip,
- project: project,
- type: :saved_input,
- body: %{"foo" => unique_val}
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- dataclip: input_dataclip,
- state: :running,
- snapshot: snapshot,
- runs: [
- build(:run,
- dataclip: input_dataclip,
- snapshot: snapshot,
- starting_job: job_1,
- state: :started
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # dataclip body is displayed
- assert view
- |> dataclip_viewer("selected-dataclip-#{input_dataclip.id}")
- |> has_element?()
-
- html = view |> element("#manual-job-#{job_1.id}") |> render()
- refute html =~ "data for this step has not been retained"
-
- # let's subscribe to events to make sure we're in sync with liveview
- # Lightning.Runs.subscribe(run)
-
- # start step without dataclip
- {:ok, %{id: step_id}} =
- Lightning.Runs.start_step(run, %{
- "job_id" => job_1.id,
- "step_id" => Ecto.UUID.generate()
- })
-
- assert_received %Lightning.Runs.Events.StepStarted{
- step: %{id: ^step_id}
- }
-
- # dataclip body is still present
- assert view
- |> dataclip_viewer("selected-dataclip-#{input_dataclip.id}")
- |> has_element?()
-
- # lets wipe the dataclip
- Lightning.Runs.wipe_dataclips(run)
-
- dataclip_id = input_dataclip.id
-
- assert_received %Lightning.Runs.Events.DataclipUpdated{
- dataclip: %{id: ^dataclip_id}
- }
-
- # make sure that the event is processed by liveview
- render(view)
-
- # dataclip body is nolonger present
- refute view
- |> dataclip_viewer("selected-dataclip-#{input_dataclip.id}")
- |> has_element?(),
- "dataclip body has been removed"
-
- html = view |> element("#manual-job-#{job_1.id}") |> render()
- assert html =~ "data for this step has not been retained"
- end
-
- test "audits snapshot creation", %{
- conn: conn,
- project: project,
- user: %{id: user_id}
- } do
- workflow =
- insert(:workflow, project: project)
- |> Lightning.Repo.preload([:jobs, :work_orders])
- |> with_snapshot()
-
- new_job_name = "new job"
-
- %{"value" => %{"id" => job_id}} =
- job_patch = add_job_patch(new_job_name)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # add a job to it but don't save
- view |> push_patches_to_view([job_patch])
-
- view |> select_node(%{id: job_id}, workflow.lock_version)
-
- view |> click_edit(%{id: job_id})
-
- view |> change_editor_text("some body")
-
- # Clear any audit entries that may have been created by fixtures
-
- Repo.delete_all(Audit)
-
- render_submit(view, "manual_run_submit", %{
- manual: %{body: Jason.encode!(%{})}
- })
-
- audit = Audit |> Repo.one()
-
- assert %{event: "snapshot_created", actor_id: ^user_id} = audit
- end
-
- test "followed crashed run without steps renders the page correctly",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _rest]} = workflow,
- snapshot: snapshot
- } do
- dataclip =
- insert(:dataclip,
- project: project,
- type: :http_request
- )
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: dataclip,
- starting_job: job_1,
- claimed_at: build(:timestamp),
- finished_at: build(:timestamp),
- started_at: nil,
- state: :crashed,
- error_type: "CompileError",
- steps: []
- )
- ]
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # user cannot rerun
- refute has_element?(view, "button", "Run (Retry)")
-
- # user can create new work order
- assert has_element?(view, "button", "Run")
-
- run_view = find_live_child(view, "run-viewer-#{run.id}")
-
- render_async(run_view)
-
- # input panel shows correct information
- html = run_view |> element("div#input-panel") |> render()
- assert html =~ "No input/output available. This step was never started."
-
- # output panel shows correct information
- html = run_view |> element("div#output-panel") |> render()
- assert html =~ "No input/output available. This step was never started."
- end
-
- test "viewer is updated correctly if manual run crashes",
- %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _rest]} = workflow
- } do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # action button is rendered correctly
- refute has_element?(view, "button", "Run (Retry)")
- refute has_element?(view, "button", "Processing")
- assert has_element?(view, "button", "Run")
-
- # submit the manual run form
- render_submit(view, "manual_run_submit", %{
- manual: %{body: "{}"}
- })
-
- uri = view |> assert_patch() |> URI.parse()
- run_id = Plug.Conn.Query.decode(uri.query)["a"]
- run = Lightning.Repo.get!(Lightning.Run, run_id)
-
- # Get the Output/Logs View
- run_view = find_live_child(view, "run-viewer-#{run.id}")
-
- # action button is rendered correctly
- refute has_element?(view, "button", "Run (Retry)")
-
- assert has_element?(view, "button:disabled", "Processing"),
- "currently processing"
-
- refute has_element?(view, "button", "Run")
-
- render_async(run_view)
- # input panel shows correct information
- html = run_view |> element("div#input-panel") |> render()
-
- assert html =~ "Nothing yet"
- refute html =~ "No input/output available. This step was never started."
-
- # output panel shows correct information
- html = run_view |> element("div#output-panel") |> render()
-
- assert html =~ "Nothing yet"
- refute html =~ "No input/output available. This step was never started."
-
- # let's subscribe to events to make sure we're in sync with liveview
- Lightning.Runs.subscribe(run)
-
- # Let's claim the run
- run =
- run
- |> Ecto.Changeset.change(%{
- state: :claimed,
- claimed_at: DateTime.utc_now()
- })
- |> Lightning.Repo.update!()
-
- # lets crash the run
- {:ok, _run} =
- Lightning.Runs.complete_run(run, %{
- "error_message" => "Unexpected token (6:9)",
- "error_type" => "CompileError",
- "final_dataclip_id" => "",
- "state" => "crashed"
- })
-
- assert_received %Lightning.Runs.Events.RunUpdated{
- run: %{id: ^run_id}
- }
-
- # make sure that the event is processed by liveview
- render(view)
-
- # action button is rendered correctly.
- refute has_element?(view, "button", "Run (Retry)")
- refute has_element?(view, "button", "Processing"), "no longer processing"
- assert has_element?(view, "button", "Run")
-
- # make sure event is processed by the run viewer
- render_async(run_view)
-
- # input panel shows correct information
- html = run_view |> element("div#input-panel") |> render()
- refute html =~ "Nothing yet"
- assert html =~ "No input/output available. This step was never started."
-
- # output panel shows correct information
- html = run_view |> element("div#output-panel") |> render()
- refute html =~ "Nothing yet"
- assert html =~ "No input/output available. This step was never started."
- end
- end
-
- describe "Editor events" do
- test "can handle request_metadata event", %{
- conn: conn,
- project: project,
- workflow: workflow
- } do
- cli_stdout = """
- {"level":"error","name":"CLI","message":["No metadata helper found"],"time":"1751556807394005966"}
- """
-
- FakeRambo.Helpers.stub_run({:ok, %{status: 0, out: cli_stdout, err: ""}})
-
- credential =
- insert(:credential, schema: "http")
- |> with_body(%{
- name: "main",
- body: %{
- "baseUrl" => "http://localhost:4002",
- "username" => "test",
- "password" => "test"
- }
- })
-
- project_credential =
- insert(:project_credential,
- project: project,
- credential: credential
- )
-
- job = workflow.jobs |> hd()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert has_element?(view, "#job-editor-pane-#{job.id}")
-
- assert view
- |> with_target("#job-editor-pane-#{job.id}")
- |> render_click("request_metadata", %{})
-
- assert_push_event(view, "metadata_ready", %{"error" => "no_credential"})
-
- view
- |> trigger_save(%{
- workflow: %{
- jobs: %{
- "0" => %{
- "project_credential_id" => project_credential.id
- }
- }
- }
- })
-
- assert view
- |> with_target("#job-editor-pane-#{job.id}")
- |> render_click("request_metadata", %{})
-
- # set timeout to 60 secs because of CI
- assert_push_event(
- view,
- "metadata_ready",
- %{
- "error" => "no_metadata_function"
- },
- 60000
- )
- end
- end
-
- describe "UI metrics events" do
- setup context do
- Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn ->
- true
- end)
-
- current_log_level = Logger.level()
- Logger.configure(level: :info)
-
- on_exit(fn ->
- Logger.configure(level: current_log_level)
- end)
-
- context
- |> Map.merge(%{
- metrics: [
- %{
- "event" => "foo-bar-job-event",
- "start" => 1_737_635_739_914,
- "end" => 1_737_635_808_890
- }
- ]
- })
- end
-
- test "writes the UI metrics to the logs", %{
- conn: conn,
- metrics: metrics,
- project: project,
- workflow: workflow
- } do
- %{id: job_id} = job = workflow.jobs |> hd()
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- fun = fn ->
- view
- |> with_target("#job-editor-pane-#{job.id}")
- |> render_hook(
- "job_editor_metrics_report",
- %{"metrics" => metrics}
- )
- end
-
- assert capture_log(fun) =~ ~r/foo-bar-job-event/
- assert capture_log(fun) =~ ~r/#{job_id}/
- end
- end
-
- describe "Output & Logs" do
- test "all users can view output and logs for a followed run", %{
- conn: conn,
- project: project,
- workflow: %{jobs: [job_1 | _rest]} = workflow,
- snapshot: snapshot
- } do
- input_dataclip =
- insert(:dataclip,
- project: project,
- type: :saved_input,
- body: %{"input" => Ecto.UUID.generate()}
- )
-
- output_dataclip =
- insert(:dataclip,
- project: project,
- type: :saved_input,
- body: %{"output" => Ecto.UUID.generate()}
- )
-
- log_line = build(:log_line)
-
- %{runs: [run]} =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- state: :success,
- runs: [
- build(:run,
- dataclip: input_dataclip,
- snapshot: snapshot,
- starting_job: job_1,
- state: :success,
- log_lines: [log_line],
- steps: [
- build(:step,
- job: job_1,
- snapshot: snapshot,
- input_dataclip: input_dataclip,
- output_dataclip: output_dataclip,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- for {conn, _user} <-
- setup_project_users(conn, project, [:owner, :admin, :editor, :viewer]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}",
- on_error: :raise
- )
-
- run_view = find_live_child(view, "run-viewer-#{run.id}")
-
- # This ensures that async result is loaded
- render_async(run_view)
- # This ensures that stream messages are processed
- render(run_view)
-
- assert has_element?(
- run_view,
- "div#log-panel [phx-hook='LogViewer'][data-run-id='#{run.id}']"
- )
-
- # input panel shows correct information
- assert view
- |> dataclip_viewer("step-input-dataclip-viewer")
- |> has_element?()
-
- input_dataclip_viewer_json =
- view
- |> dataclip_viewer("step-input-dataclip-viewer")
- |> get_attrs_and_inner_html()
- |> decode_inner_json()
- |> elem(1)
-
- assert input_dataclip_viewer_json["dataclipId"] == input_dataclip.id
-
- # output panel shows correct information
- output_dataclip_viewer_json =
- view
- |> dataclip_viewer("step-output-dataclip-viewer")
- |> get_attrs_and_inner_html()
- |> decode_inner_json()
- |> elem(1)
-
- assert output_dataclip_viewer_json["dataclipId"] == output_dataclip.id
- end
- end
- end
-
- defp rerun_setup(project, %{jobs: [job_1, job_2 | _rest]} = workflow, snapshot) do
- input_dataclip = insert(:dataclip, project: project, type: :http_request)
-
- output_dataclip =
- insert(:dataclip,
- project: project,
- type: :step_result,
- body: %{"val" => Ecto.UUID.generate()}
- )
-
- workorder =
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: input_dataclip,
- state: :success,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: input_dataclip,
- starting_job: job_1,
- state: :success,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_1,
- input_dataclip: input_dataclip,
- output_dataclip: output_dataclip,
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- ),
- build(:step,
- snapshot: snapshot,
- job: job_2,
- input_dataclip: output_dataclip,
- output_dataclip:
- build(:dataclip,
- type: :step_result,
- body: %{}
- ),
- started_at: build(:timestamp),
- finished_at: build(:timestamp),
- exit_reason: "success"
- )
- ]
- )
- ]
- )
-
- # insert 3 new dataclips
- dataclips = insert_list(3, :dataclip, project: project)
-
- # associate dataclips with job 2
- for dataclip <- dataclips do
- insert(:workorder,
- workflow: workflow,
- snapshot: snapshot,
- dataclip: dataclip,
- runs: [
- build(:run,
- snapshot: snapshot,
- dataclip: dataclip,
- starting_job: job_2,
- steps: [
- build(:step,
- snapshot: snapshot,
- job: job_2,
- input_dataclip: dataclip,
- output_dataclip: nil,
- started_at: build(:timestamp),
- finished_at: nil,
- exit_reason: nil
- )
- ]
- )
- ]
- )
- end
-
- {dataclips, workorder}
- end
-end
diff --git a/test/lightning_web/live/workflow_live/index_test.exs b/test/lightning_web/live/workflow_live/index_test.exs
index 5fef16f09f0..8aac959ffca 100644
--- a/test/lightning_web/live/workflow_live/index_test.exs
+++ b/test/lightning_web/live/workflow_live/index_test.exs
@@ -332,13 +332,6 @@ defmodule LightningWeb.WorkflowLive.IndexTest do
fn ->
view |> element("#new-workflow-button") |> render_click()
end
-
- # visit page directly
- {:ok, _, html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
- |> follow_redirect(conn)
-
- assert html =~ "You are not authorized to perform this action."
end
@tag role: :editor
@@ -351,22 +344,11 @@ defmodule LightningWeb.WorkflowLive.IndexTest do
refute has_element?(view, "#new-workflow-button:disabled")
assert has_element?(view, "#new-workflow-button")
- # go directly
- {:ok, view, html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- assert html =~ "Describe your workflow"
- assert has_element?(view, "form#search-templates-form")
-
- select_template(view, "base-webhook-template")
-
- view |> element("button#create_workflow_btn") |> render_click()
-
- # the panel disappears
- html = render(view)
+ # the collaborative editor mounts for new workflows
+ {:ok, _view, html} =
+ live(conn, ~p"/projects/#{project.id}/w/new")
- refute html =~ "Describe your workflow"
- refute has_element?(view, "form#search-templates-form")
+ assert html =~ "collaborative-editor-react"
end
test "only users with MFA enabled can create workflows for a project with MFA requirement",
diff --git a/test/lightning_web/live/workflow_live/new_workflow_component_test.exs b/test/lightning_web/live/workflow_live/new_workflow_component_test.exs
deleted file mode 100644
index bfad42c2983..00000000000
--- a/test/lightning_web/live/workflow_live/new_workflow_component_test.exs
+++ /dev/null
@@ -1,673 +0,0 @@
-defmodule LightningWeb.WorkflowLive.NewWorkflowComponentTest do
- use LightningWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
- import Lightning.Factories
- import Lightning.WorkflowLive.Helpers
-
- setup :register_and_log_in_user
- setup :create_project_for_current_user
-
- setup %{project: project} = tags do
- if Map.get(tags, :stub_apollo, true) do
- Lightning.AiAssistantHelpers.stub_online()
- end
-
- # Create 5 distinct templates using factories
- templates = [
- insert(:workflow_template, %{
- name: "Webhook Data Sync",
- description: "Sync data from webhook to database",
- tags: ["webhook", "sync", "database"],
- workflow: build(:workflow, project: project)
- }),
- insert(:workflow_template, %{
- name: "Scheduled Report Generator",
- description: "Generate reports on a schedule",
- tags: ["cron", "reports", "scheduled"],
- workflow: build(:workflow, project: project)
- }),
- insert(:workflow_template, %{
- name: "API Data Processor",
- description: "Process data from external APIs",
- tags: ["api", "data", "processing"],
- workflow: build(:workflow, project: project)
- }),
- insert(:workflow_template, %{
- name: "File Upload Handler",
- description: "Handle and process file uploads",
- tags: ["files", "upload", "storage"],
- workflow: build(:workflow, project: project)
- }),
- insert(:workflow_template, %{
- name: "Notification System",
- description: "Send notifications via email and SMS",
- tags: ["notifications", "email", "sms"],
- workflow: build(:workflow, project: project)
- })
- ]
-
- %{templates: templates}
- end
-
- defp skip_disclaimer(user, read_at \\ DateTime.utc_now() |> DateTime.to_unix()) do
- Ecto.Changeset.change(user, %{
- preferences: %{"ai_assistant.disclaimer_read_at" => read_at}
- })
- |> Lightning.Repo.update!()
- end
-
- describe "workflow creation methods" do
- @tag stub_apollo: false
- test "displays template and import options", %{conn: conn, project: project} do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Initial state should show template selection
- assert view |> element("#create-workflow-from-template") |> has_element?()
- assert view |> element("#import-workflow-btn") |> has_element?()
- refute view |> element("#workflow-importer") |> has_element?()
- end
-
- @tag stub_apollo: false
- test "switches to import view when import button is clicked", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Click import button
- html = view |> element("#import-workflow-btn") |> render_click()
-
- # Should now show the import view
- assert html =~ "Upload a file"
- assert html =~ "or drag and drop"
- assert view |> element("#workflow-importer") |> has_element?()
- assert view |> element("#workflow-dropzone") |> has_element?()
- assert view |> element("#workflow-file") |> has_element?()
- end
-
- test "can switch back to template view from import view", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Switch to import view
- view |> element("#import-workflow-btn") |> render_click()
-
- # Click back button
- _html = view |> element("#move-back-to-templates-btn") |> render_click()
-
- # Should show template selection again
- assert view |> element("#create-workflow-from-template") |> has_element?()
- refute view |> element("#workflow-importer") |> has_element?()
- end
- end
-
- describe "template selection" do
- test "displays available templates", %{conn: conn, project: project} do
- {:ok, view, html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- assert html =~ "base-webhook"
- assert html =~ "Event-based Workflow"
- assert html =~ "base-cron"
- assert html =~ "Scheduled Workflow"
-
- # Template selection form should be present
- assert view |> element("#choose-workflow-template-form") |> has_element?()
- end
-
- test "allows template selection", %{conn: conn, project: project} do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Select a template
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
-
- test "searches templates by name", %{
- conn: conn,
- project: project,
- templates: templates
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Search for each template name
- for template <- templates do
- view
- |> form("#search-templates-form", %{"search" => template.name})
- |> render_change()
-
- # Should show the template
- assert view
- |> element("#template-input-#{template.id}")
- |> has_element?()
-
- # Should still show base templates
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
-
- # Clear search
- view
- |> form("#search-templates-form", %{"search" => ""})
- |> render_change()
-
- # Should show all templates again
- for template <- templates do
- assert view
- |> element("#template-input-#{template.id}")
- |> has_element?()
- end
- end
-
- test "searches templates by description", %{
- conn: conn,
- project: project,
- templates: templates
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Search for each template description
- for template <- templates do
- view
- |> form("#search-templates-form", %{"search" => template.description})
- |> render_change()
-
- # Should show the template
- assert view
- |> element("#template-input-#{template.id}")
- |> has_element?()
-
- # Should still show base templates
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
- end
-
- test "searches templates by tags", %{
- conn: conn,
- project: project,
- templates: templates
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Test searching by specific tags
- view
- |> form("#search-templates-form", %{"search" => "webhook"})
- |> render_change()
-
- # Should show webhook template
- assert view
- |> element(
- "#template-input-#{Enum.find(templates, &(&1.name == "Webhook Data Sync")).id}"
- )
- |> has_element?()
-
- # Should still show base templates
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
-
- # Test another tag
- view
- |> form("#search-templates-form", %{"search" => "cron"})
- |> render_change()
-
- # Should show cron template
- assert view
- |> element(
- "#template-input-#{Enum.find(templates, &(&1.name == "Scheduled Report Generator")).id}"
- )
- |> has_element?()
-
- # Should still show base templates
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
-
- test "search is case insensitive", %{
- conn: conn,
- project: project,
- templates: templates
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Search with uppercase
- for template <- templates do
- view
- |> form("#search-templates-form", %{
- "search" => String.upcase(template.name)
- })
- |> render_change()
-
- # Should still find the template
- assert view
- |> element("#template-input-#{template.id}")
- |> has_element?()
-
- # Should still show base templates
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
- end
-
- test "search with no results shows base templates", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Search for non-existent term
- view
- |> form("#search-templates-form", %{"search" => "nonexistent"})
- |> render_change()
-
- # Should still show base templates
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
-
- test "search with partial matches", %{
- conn: conn,
- project: project,
- templates: templates
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Search with partial word
- for template <- templates do
- view
- |> form("#search-templates-form", %{
- "search" => String.slice(template.name, 0, 3)
- })
- |> render_change()
-
- # Should show matching templates
- assert view
- |> element("#template-input-#{template.id}")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-webhook-template")
- |> has_element?()
-
- assert view
- |> element("#template-input-base-cron-template")
- |> has_element?()
- end
- end
- end
-
- describe "workflow import" do
- test "shows file upload interface", %{conn: conn, project: project} do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Switch to import view
- html = view |> element("#import-workflow-btn") |> render_click()
-
- # Verify upload interface elements
- assert html =~ "Upload a file"
- assert html =~ "or drag and drop"
- assert html =~ "YML or YAML, up to 8MB"
- assert view |> element("#workflow-dropzone") |> has_element?()
- assert view |> element("#workflow-file") |> has_element?()
- end
-
- @tag stub_apollo: false
- test "dropzone has proper attributes for drag and drop", %{
- conn: conn,
- project: project
- } do
- Mox.stub(Lightning.MockConfig, :apollo, fn
- :endpoint -> "http://localhost:4001"
- :ai_assistant_api_key -> "ai_assistant_api_key"
- end)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- # Switch to import view
- view |> element("#import-workflow-btn") |> render_click()
-
- # Verify dropzone has necessary attributes for the JavaScript hook
- assert view
- |> element(
- "#workflow-dropzone[phx-hook='FileDropzone'][data-target='#workflow-file']"
- )
- |> has_element?()
- end
- end
-
- describe "AI method integration" do
- @tag stub_apollo: false
- test "switching to AI method without search term shows AI interface", %{
- conn: conn,
- project: project,
- user: user
- } do
- Mox.stub(Lightning.MockConfig, :apollo, fn
- :endpoint -> "http://localhost:4001"
- :ai_assistant_api_key -> "ai_assistant_api_key"
- :timeout -> 5_000
- :streaming_timeout -> 120_000
- end)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- ai_assistant = element(view, "#new-workflow-panel-assistant")
-
- refute has_element?(ai_assistant)
-
- skip_disclaimer(user)
-
- view
- |> element("#template-label-ai-dynamic-template")
- |> render_click()
-
- assert has_element?(ai_assistant)
-
- html = render(ai_assistant)
-
- assert html =~ "Start a conversation to see your chat history appear here"
- end
-
- @tag stub_apollo: false
- test "switching to AI method with search term creates session and shows AI interface",
- %{
- conn: conn,
- project: project,
- user: user
- } do
- Mox.stub(Lightning.MockConfig, :apollo, fn
- :endpoint -> "http://localhost:4001"
- :ai_assistant_api_key -> "ai_assistant_api_key"
- :timeout -> 5_000
- :streaming_timeout -> 120_000
- end)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- ai_assistant = element(view, "#new-workflow-panel-assistant")
-
- refute has_element?(ai_assistant)
-
- view
- |> form("#search-templates-form", %{"search" => "sync data from API"})
- |> render_change()
-
- skip_disclaimer(user)
-
- view
- |> element("#template-label-ai-dynamic-template")
- |> render_click()
-
- assert has_element?(ai_assistant)
-
- html = render(ai_assistant)
-
- assert html =~ "sync data from API"
- end
-
- test "AI template card displays search term correctly", %{
- conn: conn,
- project: project
- } do
- Oban.Testing.with_testing_mode(:manual, fn ->
- Mox.stub(Lightning.MockConfig, :apollo, fn key ->
- case key do
- :endpoint -> "http://localhost:3000"
- :ai_assistant_api_key -> "api_key"
- :timeout -> 5_000
- :streaming_timeout -> 120_000
- end
- end)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- view
- |> form("#search-templates-form", %{"search" => "process webhook data"})
- |> render_change()
-
- build_with_button = element(view, "#template-label-ai-dynamic-template")
-
- html = render(build_with_button)
- assert html =~ "process webhook data"
- assert html =~ "Build with AI ✨"
-
- sessions_before =
- Lightning.AiAssistant.list_sessions(project)
- |> Map.get(:sessions)
-
- assert Enum.empty?(sessions_before)
-
- build_with_button |> render_click()
-
- assert view |> element("#create_workflow_via_ai") |> has_element?()
-
- sessions_after =
- Lightning.AiAssistant.list_sessions(project)
- |> Map.get(:sessions)
-
- refute Enum.empty?(sessions_after)
-
- assert sessions_after |> Enum.any?(&(&1.title == "process webhook data"))
- end)
- end
-
- test "AI template card shows default text when no search term", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- html = render(view)
- assert html =~ "Build with AI ✨"
- assert html =~ "Build your workflow using the AI assistant"
- end
-
- test "can switch back from AI method to templates", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- view |> element("#template-label-ai-dynamic-template") |> render_click()
-
- assert view |> element("#create_workflow_via_ai") |> has_element?()
-
- html = view |> element("#move-back-to-templates-btn") |> render_click()
-
- assert html =~ "create-workflow-from-template"
- assert view |> element("#create-workflow-from-template") |> has_element?()
- refute view |> element("#create_workflow_via_ai") |> has_element?()
- end
- end
-
- describe "template selection events" do
- test "selecting a template notifies parent liveview", %{
- conn: conn,
- project: project,
- templates: [%{id: id, code: code} | _]
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- view
- |> element("#choose-workflow-template-form")
- |> render_change(%{"template_id" => id})
-
- view |> has_element?("#selected-template-label-#{id}")
-
- assert_push_event(view, "template_selected", %{template: ^code})
- end
-
- test "selecting different templates changes the selection", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- view
- |> element("#choose-workflow-template-form")
- |> render_change(%{"template_id" => "base-webhook-template"})
-
- assert_push_event(view, "template_selected", %{template: webhook_template})
-
- view
- |> element("#choose-workflow-template-form")
- |> render_change(%{"template_id" => "base-cron-template"})
-
- assert_push_event(view, "template_selected", %{template: cron_template})
-
- refute webhook_template == cron_template
- assert cron_template =~ "Scheduled Workflow"
- assert webhook_template =~ "Event-based Workflow"
- end
- end
-
- describe "workflow creation validation" do
- test "create button is disabled when no template selected", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- assert view
- |> element("#create_workflow_btn[disabled]")
- |> has_element?()
- end
-
- test "create button is enabled when template is selected", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- {view, _parsed_template} = select_template(view, "base-webhook-template")
-
- refute view
- |> element("#create_workflow_btn[disabled]")
- |> has_element?()
-
- element(view, "#create_workflow_btn") |> render_click()
-
- refute element(view, "#new-workflow-panel")
- |> has_element?()
- end
-
- test "clicking create without template shows error message", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- assert element(view, "#create_workflow_btn[disabled]") |> has_element?()
-
- view
- |> render_click("save", %{})
-
- assert render(view) =~ "Workflow could not be saved"
- end
-
- test "create button disabled in import mode when validation fails", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- element(view, "#import-workflow-btn") |> render_click()
-
- assert_patch(
- view,
- ~p"/projects/#{project.id}/w/new/legacy?method=import"
- )
-
- assert view
- |> element("#create_workflow_btn[disabled]")
- |> has_element?()
-
- view
- |> with_target("#new-workflow-panel")
- |> render_click("create_workflow", %{})
- end
-
- test "create button disabled in AI mode when no template generated", %{
- conn: conn,
- project: project
- } do
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project.id}/w/new/legacy")
-
- view |> element("#template-label-ai-dynamic-template") |> render_click()
-
- assert_patch(view, ~p"/projects/#{project.id}/w/new/legacy?method=ai")
-
- assert view
- |> element("#create_workflow_btn[disabled]")
- |> has_element?()
-
- view
- |> render_click("save")
-
- assert render(view) =~
- "Workflow could not be saved"
- end
- end
-end
diff --git a/test/lightning_web/live/workflow_live/trigger_test.exs b/test/lightning_web/live/workflow_live/trigger_test.exs
deleted file mode 100644
index a66c749aac6..00000000000
--- a/test/lightning_web/live/workflow_live/trigger_test.exs
+++ /dev/null
@@ -1,542 +0,0 @@
-defmodule LightningWeb.WorkflowLive.TriggerTest do
- use LightningWeb.ConnCase, async: true
-
- alias Lightning.Name
- alias Lightning.Repo
- alias Lightning.Workflows
- alias Lightning.Workflows.WebhookAuthMethod
-
- import Phoenix.LiveViewTest
- import Lightning.Factories
-
- setup :register_and_log_in_user
- setup :create_project_for_current_user
-
- setup %{project: project} do
- workflow = insert(:workflow, project: project)
- trigger = insert(:trigger, type: :webhook, workflow: workflow)
-
- {:ok, snapshot} = Workflows.Snapshot.create(workflow)
-
- [
- workflow: workflow,
- snapshot: snapshot,
- trigger: trigger
- ]
- end
-
- test "owner/admin can see link to add auth method, editor/viewer can't", %{
- project: project,
- workflow: workflow,
- trigger: trigger
- } do
- for conn <- build_project_user_conns(project, [:owner, :admin]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert view |> element("a#addAuthenticationLink") |> has_element?()
- end
-
- for conn <- build_project_user_conns(project, [:editor, :viewer]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert view
- |> element("a#addAuthenticationLink.cursor-not-allowed")
- |> has_element?()
- end
- end
-
- test "all users can see existing trigger authentication methods", %{
- project: project,
- workflow: workflow,
- trigger: trigger
- } do
- for conn <-
- build_project_user_conns(project, [:owner, :admin, :editor, :viewer]) do
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- auth_method =
- insert(:webhook_auth_method,
- project: project,
- auth_type: :basic,
- triggers: [trigger]
- )
-
- refute html =~ auth_method.name
-
- {:ok, _view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert html =~ auth_method.name
- end
- end
-
- test "owner/admin can successfully create a basic authentication method, editor/viewer can't",
- %{
- project: project,
- workflow: workflow,
- trigger: trigger
- } do
- modal_id = "manage_webhook_auth_methods_modal"
-
- for conn <- build_project_user_conns(project, [:editor, :viewer]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert has_element?(view, "#addAuthenticationLink.cursor-not-allowed")
-
- # forcing the event results in an error
- assert render_click(view, "show_modal", %{target: "webhook_auth_method"}) =~
- "You are not authorized to perform this action"
-
- refute has_element?(view, "##{modal_id}")
- end
-
- for conn <- build_project_user_conns(project, [:owner, :admin]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- html = view |> element("#addAuthenticationLink") |> render_click()
- refute html =~ "You are not authorized to perform this action"
- # modal is present
- assert has_element?(view, "##{modal_id}")
-
- html =
- view
- |> form("##{modal_id} form",
- webhook_auth_method: %{auth_type: "basic"}
- )
- |> render_submit()
-
- assert html =~ "Create auth method"
-
- auth_method_name = Name.generate()
-
- refute render(view) =~ auth_method_name
-
- view
- |> form("##{modal_id} form",
- webhook_auth_method: %{
- name: auth_method_name,
- username: "testusername",
- password: "testpassword123"
- }
- )
- |> render_submit()
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id]}"
- )
-
- # modal is removed
- refute has_element?(view, "##{modal_id}")
-
- html = render(view)
-
- assert html =~ "Webhook auth method created successfully"
- assert html =~ auth_method_name
-
- assert %Postgrex.Result{num_rows: 1} =
- Ecto.Adapters.SQL.query!(
- Repo,
- "delete from trigger_webhook_auth_methods"
- )
-
- Repo.get_by(WebhookAuthMethod, name: auth_method_name)
- |> Repo.delete()
- end
- end
-
- test "admin can successfully create an API authentication method, editor/viewer can't",
- %{
- project: project,
- workflow: workflow,
- trigger: trigger
- } do
- modal_id = "manage_webhook_auth_methods_modal"
-
- for conn <- build_project_user_conns(project, [:editor, :viewer]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert has_element?(view, "#addAuthenticationLink.cursor-not-allowed")
-
- # forcing the event results in an error
- assert render_click(view, "show_modal", %{target: "webhook_auth_method"}) =~
- "You are not authorized to perform this action"
-
- refute has_element?(view, "##{modal_id}")
- end
-
- for conn <- build_project_user_conns(project, [:owner, :admin]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- html = view |> element("#addAuthenticationLink") |> render_click()
- refute html =~ "You are not authorized to perform this action"
- # modal is present
- assert has_element?(view, "##{modal_id}")
-
- html =
- view
- |> form("##{modal_id} form",
- webhook_auth_method: %{auth_type: "api"}
- )
- |> render_submit()
-
- assert html =~ "Create auth method"
- assert html =~ "API Key"
- refute html =~ "password"
-
- auth_method_name = Name.generate()
-
- refute render(view) =~ auth_method_name
-
- assert view
- |> form("##{modal_id} form",
- webhook_auth_method: %{
- name: auth_method_name
- }
- )
- |> render_submit()
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id]}"
- )
-
- # modal is removed
- refute has_element?(view, "##{modal_id}")
-
- html = render(view)
-
- assert html =~ "Webhook auth method created successfully"
- assert html =~ auth_method_name
-
- assert %Postgrex.Result{num_rows: 1} =
- Ecto.Adapters.SQL.query!(
- Repo,
- "delete from trigger_webhook_auth_methods"
- )
-
- Repo.get_by(WebhookAuthMethod, name: auth_method_name)
- |> Repo.delete()
- end
- end
-
- test "users cannot update auth methods via the trigger form", %{
- project: project,
- workflow: workflow,
- trigger: trigger
- } do
- auth_method =
- insert(:webhook_auth_method,
- project: project,
- auth_type: :basic,
- triggers: [trigger]
- )
-
- modal_id = "manage_webhook_auth_methods_modal"
-
- for conn <- build_project_user_conns(project, [:owner, :admin]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> element("#manageAuthenticationLink") |> render_click()
-
- # modal is present
- assert has_element?(view, "##{modal_id}")
-
- html =
- view
- |> element("#view_auth_method_link_#{auth_method.id}")
- |> render_click()
-
- assert html =~ "Webhook Authentication Method"
- refute html =~ "Create a new webhook auth method"
-
- refute has_element?(view, "##{modal_id} form button[type='submit']")
-
- assert has_element?(
- view,
- "##{modal_id} form button[type='button']",
- "Back"
- )
-
- html =
- view
- |> element("##{modal_id} form button[type='button']", "Back")
- |> render_click()
-
- assert html =~ "Create a new webhook auth method"
- end
- end
-
- describe "revealing a webhook auth secret as an SSO user without a password" do
- setup %{project: project, trigger: trigger} do
- auth_method =
- insert(:webhook_auth_method,
- project: project,
- auth_type: :basic,
- triggers: [trigger]
- )
-
- [auth_method: auth_method]
- end
-
- test "with mfa enabled, only the 2FA code is requested", %{
- conn: conn,
- project: project,
- workflow: workflow,
- trigger: trigger,
- auth_method: auth_method
- } do
- user =
- insert(:user,
- hashed_password: nil,
- mfa_enabled: true,
- user_totp: build(:user_totp)
- )
-
- insert(:project_user, role: :owner, project: project, user: user)
- conn = log_in_user(conn, user)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> element("#manageAuthenticationLink") |> render_click()
-
- view
- |> element("#view_auth_method_link_#{auth_method.id}")
- |> render_click()
-
- component_id = "view_webhook_auth_method_#{auth_method.id}"
- html = view |> element("##{component_id} button", "Show") |> render_click()
-
- assert html =~ "2FA Code"
- refute has_element?(view, "#reauthentication-form input[type='password']")
- end
-
- test "without mfa, the user is told to set a password or enable 2fa", %{
- conn: conn,
- project: project,
- workflow: workflow,
- trigger: trigger,
- auth_method: auth_method
- } do
- user = insert(:user, hashed_password: nil, mfa_enabled: false)
-
- insert(:project_user, role: :owner, project: project, user: user)
- conn = log_in_user(conn, user)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- view |> element("#manageAuthenticationLink") |> render_click()
-
- view
- |> element("#view_auth_method_link_#{auth_method.id}")
- |> render_click()
-
- component_id = "view_webhook_auth_method_#{auth_method.id}"
- html = view |> element("##{component_id} button", "Show") |> render_click()
-
- assert html =~ "set a password or enable two-factor authentication"
- refute has_element?(view, "#reauthentication-form")
- end
- end
-
- test "owner/admin can remove an auth method from a trigger, editor/viewer can't",
- %{
- project: project,
- workflow: workflow,
- trigger: trigger
- } do
- auth_method =
- insert(:webhook_auth_method,
- project: project,
- auth_type: :basic,
- triggers: [trigger]
- )
-
- for conn <- build_project_user_conns(project, [:editor, :viewer]) do
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- assert has_element?(view, "#manageAuthenticationLink.cursor-not-allowed")
-
- # forcing the event results in an error
- assert render_click(view, "show_modal", %{target: "webhook_auth_method"}) =~
- "You are not authorized to perform this action"
-
- refute has_element?(view, "#manage_webhook_auth_methods")
- end
-
- for conn <- build_project_user_conns(project, [:owner, :admin]) do
- {:ok, view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # they can see it listed
- assert html =~ auth_method.name
-
- html = view |> element("#manageAuthenticationLink") |> render_click()
- refute html =~ "You are not authorized to perform this action"
- # modal is present
- assert has_element?(view, "#manage_webhook_auth_methods")
-
- view
- |> element("#select_#{auth_method.id}")
- |> render_click()
-
- view |> element("#update_trigger_auth_methods_button") |> render_click()
-
- assert_patched(
- view,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id]}"
- )
-
- html = render(view)
-
- assert html =~ "Trigger webhook auth methods updated successfully"
- # it is no longer listed
- refute html =~ auth_method.name
-
- # modal is closed
- refute has_element?(view, "#manage_webhook_auth_methods")
-
- updated_trigger =
- Repo.preload(trigger, [:webhook_auth_methods], force: true)
-
- assert updated_trigger.webhook_auth_methods == []
-
- # Then we add it back for the next test role! ============================
- refute has_element?(view, "#manageAuthenticationLink")
- assert has_element?(view, "#addAuthenticationLink")
- view |> element("#addAuthenticationLink") |> render_click()
-
- # modal is present
- assert has_element?(view, "#manage_webhook_auth_methods")
-
- view
- |> element("#select_#{auth_method.id}")
- |> render_click()
-
- view |> element("#update_trigger_auth_methods_button") |> render_click()
- # ========================================================================
- end
- end
-
- test "BETA chip appears for Kafka triggers but not for other trigger types", %{
- project: project,
- workflow: workflow,
- conn: conn
- } do
- # Create different types of triggers
- webhook_trigger = insert(:trigger, type: :webhook, workflow: workflow)
- cron_trigger = insert(:trigger, type: :cron, workflow: workflow)
- kafka_trigger = insert(:trigger, type: :kafka, workflow: workflow)
-
- # Test for webhook trigger
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: webhook_trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # Verify BETA chip is not present for webhook trigger
- refute view |> element("#kafka-trigger-title-beta") |> has_element?()
-
- refute view
- |> element("span[aria-label*='Kafka triggers are currently in beta']")
- |> has_element?()
-
- # Test for cron trigger
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: cron_trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # Verify BETA chip is not present for cron trigger
- refute view |> element("#kafka-trigger-title-beta") |> has_element?()
-
- refute view
- |> element("span[aria-label*='Kafka triggers are currently in beta']")
- |> has_element?()
-
- # Test for kafka trigger
- {:ok, view, html} =
- live(
- conn,
- ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: kafka_trigger.id, v: workflow.lock_version]}",
- on_error: :raise
- )
-
- # Verify BETA chip is present for kafka trigger
- assert view |> element("#kafka-trigger-title-beta") |> has_element?()
-
- # Verify tooltip content
- assert html =~ "Kafka triggers are currently in beta"
- assert html =~ "Learn about the sharp edges"
-
- assert html =~
- "https://docs.openfn.org/documentation/build/triggers#known-sharp-edges-on-the-kafka-trigger-feature"
- end
-end
diff --git a/test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs b/test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs
deleted file mode 100644
index a8fb8d9ba97..00000000000
--- a/test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs
+++ /dev/null
@@ -1,696 +0,0 @@
-defmodule LightningWeb.WorkflowLive.WorkflowAiChatComponentTest do
- use LightningWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
- import Lightning.Factories
- import Lightning.AiAssistantHelpers
- import Mox
- import Ecto.Query
-
- setup :register_and_log_in_user
- setup :create_project_for_current_user
- setup :verify_on_exit!
-
- setup %{project: project} do
- workflow = insert(:simple_workflow, project: project)
- {:ok, snapshot} = Lightning.Workflows.Snapshot.create(workflow)
-
- # Stub Apollo as online
- Mox.stub(Lightning.MockConfig, :apollo, fn
- :endpoint -> "http://localhost:4001"
- :ai_assistant_api_key -> "test_api_key"
- :timeout -> 5_000
- :streaming_timeout -> 120_000
- end)
-
- %{workflow: workflow, snapshot: snapshot}
- end
-
- defp skip_disclaimer(user, read_at \\ DateTime.utc_now() |> DateTime.to_unix()) do
- Ecto.Changeset.change(user, %{
- preferences: %{"ai_assistant.disclaimer_read_at" => read_at}
- })
- |> Lightning.Repo.update!()
- end
-
- describe "component mounting and rendering" do
- test "renders the AI chat panel with correct structure", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- assert has_element?(view, "#workflow-ai-chat-panel")
- assert has_element?(view, "#workflow-ai-chat-panel-assistant")
- end
- end
-
- describe "AI workflow generation" do
- test "generates and applies valid workflow template", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- valid_workflow_yaml = """
- name: Updated Workflow
- jobs:
- fetch_data:
- name: Fetch Data
- adaptor: '@openfn/language-http@latest'
- body: |
- get('/api/data');
- triggers:
- webhook:
- type: webhook
- enabled: true
- edges:
- webhook->fetch_data:
- source_trigger: webhook
- target_job: fetch_data
- condition_type: always
- """
-
- stub_ai_with_health_check("http://localhost:4001", %{
- "response" => "I'll update your workflow",
- "response_yaml" => valid_workflow_yaml,
- "usage" => %{},
- "history" => [
- %{"role" => "user", "content" => "Add a fetch data job"},
- %{
- "role" => "assistant",
- "content" => "I'll update your workflow"
- }
- ]
- })
-
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- render_async(view)
-
- view
- |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant")
- |> render_submit(%{"assistant" => %{"content" => "Add a fetch data job"}})
-
- assert_push_event(view, "template_selected", %{template: template})
- assert template =~ "name: Updated Workflow"
- assert template =~ "fetch_data"
-
- job_id = Ecto.UUID.generate()
- trigger_id = Ecto.UUID.generate()
- edge_id = Ecto.UUID.generate()
-
- parsed_params = %{
- "name" => "Updated Workflow",
- "jobs" => [
- %{
- "id" => job_id,
- "name" => "Fetch Data",
- "adaptor" => "@openfn/language-http@latest",
- "body" => "get('/api/data');"
- }
- ],
- "triggers" => [
- %{
- "id" => trigger_id,
- "type" => "webhook",
- "enabled" => true
- }
- ],
- "edges" => [
- %{
- "id" => edge_id,
- "source_trigger_id" => trigger_id,
- "target_job_id" => job_id,
- "condition_type" => "always"
- }
- ]
- }
-
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parsed", %{"workflow" => parsed_params})
- end)
-
- assert_receive {_ref,
- {:push_event, "template_selected", %{template: template}}}
-
- assert template =~ "Updated Workflow"
- end
-
- test "handles YAML parse errors from JavaScript", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- invalid_yaml = """
- name: Bad Workflow
- jobs:
- - this is invalid yaml structure
- body: |
- """
-
- stub_ai_with_health_check("http://localhost:4001", %{
- "response" => "Here's your workflow",
- "response_yaml" => invalid_yaml,
- "usage" => %{}
- })
-
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- render_async(view)
-
- view
- |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant")
- |> render_submit(%{"assistant" => %{"content" => "Create a bad workflow"}})
-
- assert_push_event(view, "template_selected", %{template: template})
- assert template =~ "Bad Workflow"
-
- message =
- Lightning.Repo.one(
- from(m in Lightning.AiAssistant.ChatMessage,
- where: m.role == :assistant,
- order_by: [desc: m.inserted_at],
- limit: 1
- )
- )
-
- view
- |> element(
- "[phx-click='select_assistant_message'][phx-value-message-id='#{message.id}']"
- )
- |> render_click()
-
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parse-error", %{
- "error" => "Invalid YAML: unexpected scalar at line 3"
- })
- end)
-
- html = render(view)
- assert html =~ "Error while parsing workflow"
- assert html =~ "Click to view error details"
-
- assert has_element?(view, "#error-details-#{message.id}")
- assert html =~ "Invalid YAML: unexpected scalar at line 3"
- end
-
- test "handles validation errors when parsed workflow is invalid", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- stub_ai_with_health_check("http://localhost:4001", %{
- "response" => "Here's a workflow with validation issues",
- "response_yaml" => """
- name: ""
- jobs:
- empty_job:
- name: ""
- adaptor: ""
- body: ""
- """,
- "usage" => %{}
- })
-
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- render_async(view)
-
- view
- |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant")
- |> render_submit(%{
- "assistant" => %{"content" => "Create invalid workflow"}
- })
-
- assert_push_event(view, "template_selected", %{template: _})
-
- render_async(view)
-
- message =
- Lightning.Repo.one(
- from(m in Lightning.AiAssistant.ChatMessage,
- where: m.role == :assistant,
- order_by: [desc: m.inserted_at],
- limit: 1
- )
- )
-
- view
- |> element(
- "[phx-click='select_assistant_message'][phx-value-message-id='#{message.id}']"
- )
- |> render_click()
-
- invalid_params = %{
- "name" => "",
- "jobs" => [
- %{
- "id" => Ecto.UUID.generate(),
- "name" => "",
- "adaptor" => "",
- "body" => ""
- }
- ]
- }
-
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parsed", %{"workflow" => invalid_params})
- end)
-
- html = render_async(view)
-
- assert html =~ "Error while parsing workflow"
-
- assert has_element?(view, "button[phx-click*='error-details']")
- assert has_element?(view, "#error-details-#{message.id}")
-
- assert html =~ "name - can't be blank"
- assert html =~ "jobs.1.body - job body can't be blank"
- assert html =~ "jobs.1.name - job name can't be blank"
- end
-
- test "clicking on AI message with code restores workflow", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- skip_disclaimer(user)
-
- session =
- insert(:chat_session,
- project: project,
- workflow: workflow,
- user: user,
- session_type: "workflow_template"
- )
-
- workflow_code = """
- name: Previous Workflow
- jobs:
- old_job:
- name: Old Job
- adaptor: '@openfn/language-common@latest'
- body: |
- console.log("old");
- """
-
- message =
- insert(:chat_message,
- chat_session: session,
- role: :assistant,
- content: "Here's your previous workflow",
- code: workflow_code
- )
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai&w-chat=#{session.id}"
- )
-
- assert_push_event(view, "template_selected", %{template: ^workflow_code})
-
- view
- |> element(
- "[phx-click='select_assistant_message'][phx-value-message-id='#{message.id}']"
- )
- |> render_click()
-
- assert_push_event(view, "template_selected", %{template: ^workflow_code})
- end
- end
-
- describe "complex validation error scenarios" do
- test "handles multiple job errors with proper naming", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- Oban.Testing.with_testing_mode(:manual, fn ->
- workflow_yaml = """
- name: Multi-job Workflow
- jobs:
- first_job:
- name: First Job
- adaptor: ""
- body: console.log('valid');
- second_job:
- name: Second Job
- adaptor: @openfn/language-common@latest
- body: ""
- third_job:
- name: ""
- adaptor: @openfn/language-common@latest
- body: fn(state => state)
- triggers:
- webhook:
- type: webhook
- edges:
- webhook->first_job:
- source_trigger: webhook
- target_job: first_job
- """
-
- Mox.stub(Lightning.MockConfig, :apollo, fn key ->
- case key do
- :endpoint -> "http://localhost:3000"
- :ai_assistant_api_key -> "api_key"
- :timeout -> 5_000
- :streaming_timeout -> 120_000
- end
- end)
-
- stub_ai_with_health_check("http://localhost:3000", %{
- "response" => "Here's a workflow with validation issues",
- "response_yaml" => workflow_yaml,
- "usage" => %{},
- "history" => [
- %{
- "role" => "user",
- "content" => "Create workflow with errors"
- },
- %{
- "role" => "assistant",
- "content" => "Here's a workflow with validation issues"
- }
- ]
- })
-
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai"
- )
-
- render_async(view)
-
- view
- |> form("#ai-assistant-form-workflow-ai-chat-panel-assistant")
- |> render_submit(%{
- assistant: %{content: "Create workflow with errors"}
- })
-
- render_async(view)
-
- assert_push_event(view, "template_selected", %{template: template})
- assert template =~ workflow_yaml
-
- message =
- Lightning.Repo.one(
- from(m in Lightning.AiAssistant.ChatMessage,
- where: m.role == :assistant,
- order_by: [desc: m.inserted_at],
- limit: 1
- )
- )
-
- element(
- view,
- "div[phx-value-message-id='#{message.id}']"
- )
- |> render_click()
-
- params_with_job_errors = %{
- "name" => "Multi-job Workflow",
- "jobs" => [
- %{
- "id" => Ecto.UUID.generate(),
- "name" => "First Job",
- "adaptor" => "",
- "body" => "console.log('valid');"
- },
- %{
- "id" => Ecto.UUID.generate(),
- "name" => "Second Job",
- "adaptor" => "@openfn/language-common@latest",
- "body" => ""
- },
- %{
- "id" => Ecto.UUID.generate(),
- "name" => "",
- "adaptor" => "@openfn/language-common@latest",
- "body" => "fn(state => state)"
- }
- ],
- "triggers" => [
- %{
- "id" => Ecto.UUID.generate(),
- "type" => "webhook"
- }
- ],
- "edges" => [
- %{
- "id" => Ecto.UUID.generate(),
- "source_trigger_id" => "trigger-id",
- "target_job_id" => "job-id"
- }
- ]
- }
-
- log =
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parsed", %{
- "workflow" => params_with_job_errors
- })
- end)
-
- assert log =~
- "Workflow code parse failed:"
-
- html = render(view)
-
- assert html =~ "Error while parsing workflow"
-
- assert has_element?(view, "button[phx-click*='error-details']")
- assert has_element?(view, "#error-details-#{message.id}")
-
- assert html =~ "job name can't be blank"
- end)
- end
-
- test "handles workflow yaml parse errors", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- Oban.Testing.with_testing_mode(:manual, fn ->
- workflow_yaml = "unparseable workflow"
-
- Mox.stub(Lightning.MockConfig, :apollo, fn key ->
- case key do
- :endpoint -> "http://localhost:3000"
- :ai_assistant_api_key -> "api_key"
- :timeout -> 5_000
- :streaming_timeout -> 120_000
- end
- end)
-
- stub_ai_with_health_check("http://localhost:3000", %{
- "response" => "Here's a workflow with validation issues",
- "response_yaml" => workflow_yaml,
- "usage" => %{},
- "history" => [
- %{
- "role" => "user",
- "content" => "Create workflow with errors"
- },
- %{
- "role" => "assistant",
- "content" => "Here's a workflow with validation issues"
- }
- ]
- })
-
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai"
- )
-
- render_async(view)
-
- view
- |> form("#ai-assistant-form-workflow-ai-chat-panel-assistant")
- |> render_submit(%{
- assistant: %{content: "Create workflow with errors"}
- })
-
- render_async(view)
-
- assert_push_event(view, "template_selected", %{template: template})
- assert template =~ workflow_yaml
-
- message =
- Lightning.Repo.one(
- from(m in Lightning.AiAssistant.ChatMessage,
- where: m.role == :assistant,
- order_by: [desc: m.inserted_at],
- limit: 1
- )
- )
-
- element(
- view,
- "div[phx-value-message-id='#{message.id}']"
- )
- |> render_click()
-
- log =
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parse-error", %{
- "error" => "workflow format unknown"
- })
- end)
-
- assert log =~ "Workflow code parse failed: \"workflow format unknown\""
-
- html = render(view)
-
- assert html =~ "Error while parsing workflow"
-
- assert has_element?(view, "button[phx-click*='error-details']")
- assert has_element?(view, "#error-details-#{message.id}")
-
- assert html =~ "workflow format unknown"
- end)
- end
- end
-
- describe "AI assistant state management" do
- test "shows loading state when sending message", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- stub_ai_with_health_check("http://localhost:4001", %{
- "response" => "Processing...",
- "response_yaml" => nil,
- "usage" => %{}
- })
-
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- render_async(view)
-
- view
- |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant")
- |> render_submit(%{"assistant" => %{"content" => "Update workflow"}})
-
- # Canvas should be notified about sending state - we disable when we send the message
- assert_receive {_ref, {:push_event, "set-disabled", %{disabled: true}}}
-
- # Canvas should be notified when done - we enable when we receive the message
- assert_receive {_ref, {:push_event, "set-disabled", %{disabled: false}}}
- end
-
- test "preserves workflow params between updates", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- job_id = Ecto.UUID.generate()
-
- initial_params = %{
- "name" => "Initial Workflow",
- "jobs" => [
- %{
- "id" => job_id,
- "name" => "Initial Job",
- "adaptor" => "@openfn/language-common@latest",
- "body" => "fn(state => state)"
- }
- ]
- }
-
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parsed", %{"workflow" => initial_params})
- end)
-
- assert_receive {_ref, {:push_event, "patches-applied", _patches}}
-
- # Send same params again - should not trigger update
- ExUnit.CaptureLog.capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parsed", %{"workflow" => initial_params})
- end)
-
- # Should not receive another notification
- refute_receive {_ref, {:push_event, "patches-applied", _patches}}
- end
- end
-
- describe "error logging" do
- test "logs YAML parse errors", %{
- conn: conn,
- project: project,
- workflow: workflow,
- user: user
- } do
- skip_disclaimer(user)
-
- {:ok, view, _html} =
- live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai")
-
- import ExUnit.CaptureLog
-
- log =
- capture_log(fn ->
- view
- |> with_target("#workflow-ai-chat-panel")
- |> render_hook("template-parse-error", %{
- "error" => "YAML syntax error at line 42: unexpected end of stream"
- })
- end)
-
- assert log =~ "Workflow code parse failed"
- assert log =~ "YAML syntax error at line 42"
- end
- end
-end