diff --git a/front/assets/js/utils.js b/front/assets/js/utils.js
index 245589d82..5812d858f 100644
--- a/front/assets/js/utils.js
+++ b/front/assets/js/utils.js
@@ -60,4 +60,19 @@ export class Utils {
static endOfWeek(date) {
return moment(date).endOf('isoWeek');
}
+
+ // Escapes CSS attribute values
+ // Uses CSS.escape() when available, with fallback for older browsers
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape
+ static escapeCSSAttributeValue(value) {
+ if (!value) return value;
+
+ // Use native CSS.escape() if available
+ if (typeof CSS !== 'undefined' && CSS.escape) {
+ return CSS.escape(value);
+ }
+
+ // Fallback for older browsers: escape quotes and backslashes
+ return value.replace(/(["'\\])/g, '\\$1');
+ }
}
diff --git a/front/assets/js/utils.spec.js b/front/assets/js/utils.spec.js
index 4036953f4..eb0a98518 100644
--- a/front/assets/js/utils.spec.js
+++ b/front/assets/js/utils.spec.js
@@ -21,4 +21,97 @@ describe("Utils", () => {
expect(Utils.toSeconds(36000000000)).to.equal(36)
});
});
+ describe("escapeCSSAttributeValue", () => {
+ it("escapes single quotes", () => {
+ const input = "Publish 'my-package' to Production";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ const expected = "Publish \\'my-package\\' to Production";
+ expect(escaped).to.equal(expected);
+ });
+
+ it("escapes double quotes", () => {
+ const input = 'Deploy "production" build';
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ const expected = 'Deploy \\"production\\" build';
+ expect(escaped).to.equal(expected);
+ });
+
+ it("escapes backslashes", () => {
+ const input = "Path\\to\\file";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ const expected = "Path\\\\to\\\\file";
+ expect(escaped).to.equal(expected);
+ });
+
+ it("escapes special characters", () => {
+ const input = "test[data]:value.class#id";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ // When CSS.escape is available, it escapes brackets, colons, etc.
+ // Fallback only escapes quotes and backslashes
+ if (typeof CSS !== 'undefined' && CSS.escape) {
+ expect(escaped).to.not.equal(input);
+ } else {
+ expect(escaped).to.equal(input);
+ }
+ });
+
+ it("handles simple strings", () => {
+ const input = "Simple-promotion_name123";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ // Simple alphanumeric strings with hyphens/underscores shouldn't need escaping
+ expect(escaped).to.equal(input);
+ });
+
+ it("handles empty string", () => {
+ expect(Utils.escapeCSSAttributeValue("")).to.equal("");
+ });
+
+ it("handles null/undefined", () => {
+ expect(Utils.escapeCSSAttributeValue(null)).to.equal(null);
+ expect(Utils.escapeCSSAttributeValue(undefined)).to.equal(undefined);
+ });
+
+ it("escapes complex strings with brackets and quotes", () => {
+ const input = "test'][arbitrary-selector][data-x='some-value";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ const expected = "test\\'][arbitrary-selector][data-x=\\'some-value";
+ expect(escaped).to.equal(expected);
+ });
+
+ it("can be used in DOM selectors", () => {
+ const input = "Publish 'my-package' to Production";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ const expected = "Publish \\'my-package\\' to Production";
+ expect(escaped).to.equal(expected);
+
+ expect(() => {
+ document.querySelector(`[data-promotion-target="${escaped}"]`);
+ }).to.not.throw();
+ });
+
+ it("handles unicode characters (emoji)", () => {
+ const input = "Deploy 🚀 to production";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ expect(escaped).to.equal(input);
+ });
+
+ it("handles unicode characters (accented letters)", () => {
+ const input = "Déploiement en français";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ expect(escaped).to.equal(input);
+ });
+
+ it("handles unicode characters (Chinese)", () => {
+ const input = "部署到生产环境";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ expect(escaped).to.equal(input);
+ });
+
+ it("handles mixed unicode and special characters", () => {
+ const input = "Deploy 'app' 🚀 to Prod";
+ const escaped = Utils.escapeCSSAttributeValue(input);
+ const expected = "Deploy \\'app\\' 🚀 to Prod";
+ expect(escaped).to.equal(expected);
+ });
+ });
})
diff --git a/front/assets/js/workflow_view/switch.js b/front/assets/js/workflow_view/switch.js
index 9ce9d1d37..1a9a05df6 100644
--- a/front/assets/js/workflow_view/switch.js
+++ b/front/assets/js/workflow_view/switch.js
@@ -3,6 +3,7 @@ import $ from "jquery"
import { TriggerEvent } from "./trigger_event"
import { Pollman } from "../pollman"
import { TargetParams } from "./target_params"
+import { Utils } from "../utils"
export var Switch = {
init: function() {
@@ -40,7 +41,8 @@ export var Switch = {
Switch.askToConfirmPromotion(switchId, promotionTarget);
// Focus on first input or select in the promotion box
- const promotionForm = $(`[data-promotion-target="${promotionTarget}"]`);
+ const escapedPromotionTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ const promotionForm = $(`[data-promotion-target="${escapedPromotionTarget}"]`);
const firstInput = promotionForm.find('input, select').first();
if (firstInput.length) {
if (firstInput[0].tomselect) {
@@ -82,8 +84,9 @@ export var Switch = {
Pollman.start();
alert("Something went wrong. Please try again.");
- $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${parentPromotionTarget}'][promote-button]`).removeAttr("disabled");
- $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${parentPromotionTarget}'][promote-button]`).removeClass("btn-working");
+ let escapedTarget = Utils.escapeCSSAttributeValue(parentPromotionTarget);
+ $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).removeAttr("disabled");
+ $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).removeClass("btn-working");
});
})
},
@@ -96,9 +99,10 @@ export var Switch = {
let promotionTarget = Switch.parentPromotionTarget(target)
let promotionSwitch = Switch.parentSwitch(target)
+ let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-confirmation]`).hide();
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).show();
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-confirmation]`).hide();
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).show();
Pollman.pollNow();
Pollman.start();
@@ -138,18 +142,21 @@ export var Switch = {
askToConfirmPromotion: function(promotionSwitch, promotionTarget) {
Switch.hidePromotionBoxElements(promotionSwitch, promotionTarget);
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-confirmation]`).show();
+ let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-confirmation]`).show();
},
hidePromotionBoxElements: function(promotionSwitch, promotionTarget) {
- $(`[switch='${promotionSwitch}'] [promotion-box][data-promotion-target='${promotionTarget}']`).children().hide();
+ let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ $(`[switch='${promotionSwitch}'] [promotion-box][data-promotion-target='${escapedTarget}']`).children().hide();
},
showPromotingInProgress(promotionSwitch, promotionTarget) {
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-confirmation]`).hide();
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).show();
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).attr("disabled", "");
- $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).addClass("btn-working");
+ let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-confirmation]`).hide();
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).show();
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).attr("disabled", "");
+ $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).addClass("btn-working");
Switch.afterResize(promotionSwitch);
},
@@ -167,7 +174,8 @@ export var Switch = {
},
latestTriggerEvent: function(promotionSwitch, promotionTarget) {
- return $(`[switch='${promotionSwitch}'] [trigger-event][data-promotion-target='${promotionTarget}']`).first();
+ let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ return $(`[switch='${promotionSwitch}'] [trigger-event][data-promotion-target='${escapedTarget}']`).first();
},
isProcessed: function(triggerEvent) {
diff --git a/front/assets/js/workflow_view/switch.spec.js b/front/assets/js/workflow_view/switch.spec.js
new file mode 100644
index 000000000..02d50a731
--- /dev/null
+++ b/front/assets/js/workflow_view/switch.spec.js
@@ -0,0 +1,257 @@
+/**
+ * @prettier
+ */
+
+import { expect } from "chai";
+import { Switch } from "./switch";
+import { Utils } from "../utils";
+import $ from "jquery";
+import sinon from "sinon";
+
+describe("Switch", () => {
+ beforeEach(() => {
+ // Setup DOM for tests
+ document.body.innerHTML = `
+
+ `;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ $("body").off(); // Clean up event handlers
+ });
+
+ describe("promotion target name escaping", () => {
+ it("handles promotion targets with single quotes", () => {
+ const promotionName = "Publish 'my-package' to Production";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+ const expected = "Publish \\'my-package\\' to Production";
+
+ expect(escaped).to.equal(expected);
+ expect(() => {
+ $(`[data-promotion-target="${escaped}"]`);
+ }).to.not.throw();
+ });
+
+ it("handles promotion targets with double quotes", () => {
+ const promotionName = 'Deploy "production" build';
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+ const expected = 'Deploy \\"production\\" build';
+
+ expect(escaped).to.equal(expected);
+ expect(() => {
+ $(`[data-promotion-target="${escaped}"]`);
+ }).to.not.throw();
+ });
+
+ it("handles promotion targets with complex special characters", () => {
+ const promotionName = "test'][arbitrary-selector][data-x='some-value";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+ const expected = "test\\'][arbitrary-selector][data-x=\\'some-value";
+
+ expect(escaped).to.equal(expected);
+ expect(() => {
+ $(`[switch='123'] [data-promotion-target='${escaped}'][promote-button]`);
+ }).to.not.throw();
+ });
+
+ it("handles promotion targets with brackets and colons", () => {
+ const promotionName = "test[data]:value.class#id";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+
+ // CSS.escape handles these when available, fallback doesn't need to
+ if (typeof CSS !== 'undefined' && CSS.escape) {
+ expect(escaped).to.not.equal(promotionName);
+ } else {
+ expect(escaped).to.equal(promotionName);
+ }
+ });
+
+ it("handles promotion targets with emoji", () => {
+ const promotionName = "Deploy 🚀 to Production";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+
+ expect(escaped).to.equal(promotionName);
+ });
+
+ it("handles promotion targets with accented characters", () => {
+ const promotionName = "Déploiement Français";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+
+ expect(escaped).to.equal(promotionName);
+ });
+
+ it("handles promotion targets with CJK characters", () => {
+ const promotionName = "部署到生产环境";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+
+ expect(escaped).to.equal(promotionName);
+ });
+
+ it("handles mixed unicode and quotes", () => {
+ const promotionName = "Deploy 'app' 🎉 to Staging";
+ const escaped = Utils.escapeCSSAttributeValue(promotionName);
+ const expected = "Deploy \\'app\\' 🎉 to Staging";
+
+ expect(escaped).to.equal(expected);
+ });
+ });
+
+ describe("hidePromotionBoxElements", () => {
+ it("escapes promotion target before using in selector", () => {
+ const promotionTarget = "Deploy 'prod' build";
+ document.body.innerHTML = `
+
+ `;
+
+ Switch.hidePromotionBoxElements("switch-1", promotionTarget);
+
+ // The child should be hidden (display: none)
+ const child = $("[switch='switch-1'] [promotion-box] .child");
+ expect(child.css("display")).to.equal("none");
+ });
+ });
+
+ describe("askToConfirmPromotion", () => {
+ it("escapes promotion target and shows confirmation", () => {
+ const promotionTarget = "Test 'target' name";
+ const escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ document.body.innerHTML = `
+
+ `;
+
+ sinon.stub(Switch, "hidePromotionBoxElements");
+
+ Switch.askToConfirmPromotion("switch-1", promotionTarget);
+
+ // Verify hidePromotionBoxElements was called
+ expect(Switch.hidePromotionBoxElements.calledOnce).to.be.true;
+
+ // The confirmation element should have show() called (display not 'none')
+ const confirmation = $(`[switch='switch-1'] [promote-confirmation][data-promotion-target='${escapedTarget}']`);
+ expect(confirmation.css("display")).to.not.equal("none");
+
+ Switch.hidePromotionBoxElements.restore();
+ });
+ });
+
+ describe("showPromotingInProgress", () => {
+ it("safely handles escaped promotion targets", () => {
+ const promotionTarget = "Deploy \"staging\"";
+ const escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget);
+ document.body.innerHTML = `
+
+ `;
+
+ Switch.showPromotingInProgress("switch-1", promotionTarget);
+
+ const confirmation = $(`[switch='switch-1'] [promote-confirmation][data-promotion-target='${escapedTarget}']`);
+ const button = $(`[switch='switch-1'] [promote-button][data-promotion-target='${escapedTarget}']`);
+
+ expect(confirmation.css("display")).to.equal("none");
+ expect(button.css("display")).to.not.equal("none");
+ expect(button.prop("disabled")).to.be.true;
+ expect(button.hasClass("btn-working")).to.be.true;
+ });
+ });
+
+ describe("latestTriggerEvent", () => {
+ it("finds trigger event with escaped promotion target", () => {
+ const promotionTarget = "Deploy 'production'";
+ document.body.innerHTML = `
+
+ `;
+
+ const result = Switch.latestTriggerEvent("switch-1", promotionTarget);
+
+ expect(result.length).to.equal(1);
+ expect(result.attr("data-id")).to.equal("event-1");
+ });
+ });
+
+ describe("parentSwitch", () => {
+ it("returns the switch ID from target element", () => {
+ const target = $("");
+ expect(Switch.parentSwitch(target)).to.equal("switch-456");
+ });
+ });
+
+ describe("parentPromotionTarget", () => {
+ it("returns the promotion target from target element", () => {
+ const target = $("");
+ expect(Switch.parentPromotionTarget(target)).to.equal("My Target");
+ });
+ });
+
+ describe("hasEmptyRequiredParameter", () => {
+ it("returns true when required input is empty", () => {
+ const form = $(`
+
+ `);
+
+ expect(Switch.hasEmptyRequiredParameter(form)).to.be.true;
+ });
+
+ it("returns false when all required inputs are filled", () => {
+ const form = $(`
+
+ `);
+
+ expect(Switch.hasEmptyRequiredParameter(form)).to.be.false;
+ });
+
+ it("returns true when required select has no value", () => {
+ // Create a select with no options - .val() will return null
+ const form = $(`
+
+ `);
+
+ expect(Switch.hasEmptyRequiredParameter(form)).to.be.true;
+ });
+ });
+
+ describe("isProcessed", () => {
+ it("returns true when trigger event is processed", () => {
+ const triggerEvent = $("");
+ expect(Switch.isProcessed(triggerEvent)).to.be.true;
+ });
+
+ it("returns false when trigger event is not processed", () => {
+ const triggerEvent = $("");
+ expect(Switch.isProcessed(triggerEvent)).to.be.false;
+ });
+
+ it("returns false when trigger event is null", () => {
+ expect(Switch.isProcessed(null)).to.not.be.true;
+ });
+ });
+});
diff --git a/front/docker-compose.yml b/front/docker-compose.yml
index 809077c90..d4ce8ef37 100644
--- a/front/docker-compose.yml
+++ b/front/docker-compose.yml
@@ -45,6 +45,11 @@ services:
tty: true
volumes:
- .:/app
+ - /app/_build
+ - /app/deps
+ - /app/assets/node_modules
+ - /app/assets/lib
+ - /app/assets/bin
redis-cache:
image: "redis:5-buster"
diff --git a/front/test/browser/workflow_page/promotions_test.exs b/front/test/browser/workflow_page/promotions_test.exs
index 087109d5f..c90630b23 100644
--- a/front/test/browser/workflow_page/promotions_test.exs
+++ b/front/test/browser/workflow_page/promotions_test.exs
@@ -82,6 +82,113 @@ defmodule Front.Browser.WorkflowPage.PromotionsTest do
assert has_text?(page, "Nevermind")
end
+ test "promotion targets with single quotes render and open correctly", ctx do
+ quoted_name = "Publish 'my-package' to Production"
+ Support.Stubs.Switch.add_target(ctx.switch, name: quoted_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(quoted_name))
+ click(page, Query.button(quoted_name))
+
+ assert_has(
+ page,
+ Query.css("[promote-confirmation][data-promotion-target=\"#{quoted_name}\"]")
+ )
+
+ assert_has(page, Query.text("Promote to #{quoted_name}?"))
+ end
+
+ test "promotion targets with double quotes render and open correctly", ctx do
+ quoted_name = ~s(Deploy "production" build)
+ Support.Stubs.Switch.add_target(ctx.switch, name: quoted_name)
+
+ page = open(ctx)
+
+ # Use CSS selector with data attribute since XPath queries don't handle embedded quotes
+ click(page, Query.css("[promote-button][data-promotion-target='#{quoted_name}']"))
+
+ # Verify confirmation dialog appeared
+ assert_has(page, Query.css("[promote-confirmation][data-promotion-target='#{quoted_name}']"))
+ end
+
+ test "promotion targets with backslashes render and open correctly", ctx do
+ name_with_backslash = "Deploy\\Staging\\App"
+ Support.Stubs.Switch.add_target(ctx.switch, name: name_with_backslash)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(name_with_backslash))
+ click(page, Query.button(name_with_backslash))
+ assert_has(page, Query.text("Promote to #{name_with_backslash}?"))
+ end
+
+ test "promotion targets with brackets and special characters work correctly", ctx do
+ special_name = "Deploy[test]:value.config"
+ Support.Stubs.Switch.add_target(ctx.switch, name: special_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(special_name))
+ click(page, Query.button(special_name))
+ assert_has(page, Query.text("Promote to #{special_name}?"))
+ end
+
+ test "promotion targets with emoji render and open correctly", ctx do
+ emoji_name = "Deploy 🚀 to Production"
+ Support.Stubs.Switch.add_target(ctx.switch, name: emoji_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(emoji_name))
+ click(page, Query.button(emoji_name))
+ assert_has(page, Query.text("Promote to #{emoji_name}?"))
+ end
+
+ test "promotion targets with accented characters render and open correctly", ctx do
+ accented_name = "Déploiement Français"
+ Support.Stubs.Switch.add_target(ctx.switch, name: accented_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(accented_name))
+ click(page, Query.button(accented_name))
+ assert_has(page, Query.text("Promote to #{accented_name}?"))
+ end
+
+ test "promotion targets with CJK characters render and open correctly", ctx do
+ cjk_name = "部署到生产环境"
+ Support.Stubs.Switch.add_target(ctx.switch, name: cjk_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(cjk_name))
+ click(page, Query.button(cjk_name))
+ assert_has(page, Query.text("Promote to #{cjk_name}?"))
+ end
+
+ test "promotion targets with mixed special characters and unicode work correctly", ctx do
+ mixed_name = "Deploy 'app' 🎉 to Production"
+ Support.Stubs.Switch.add_target(ctx.switch, name: mixed_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(mixed_name))
+ click(page, Query.button(mixed_name))
+ assert_has(page, Query.text("Promote to #{mixed_name}?"))
+ end
+
+ test "promotion targets with complex bracket and quote combinations work correctly", ctx do
+ complex_name = "test[data] 'value' config"
+ Support.Stubs.Switch.add_target(ctx.switch, name: complex_name)
+
+ page = open(ctx)
+
+ assert_has(page, Query.button(complex_name))
+ click(page, Query.button(complex_name))
+ assert_has(page, Query.text("Promote to #{complex_name}?"))
+ end
+
describe "when deployment targets are enabled" do
setup ctx do
Support.Stubs.Feature.enable_feature(ctx.org.id, :deployment_targets)
diff --git a/front/test/browser/workflow_page_test.exs b/front/test/browser/workflow_page_test.exs
index 51a79f972..59b2312a7 100644
--- a/front/test/browser/workflow_page_test.exs
+++ b/front/test/browser/workflow_page_test.exs
@@ -42,6 +42,53 @@ defmodule Front.Browser.WorkflowPage do
assert_text(page, "Staging")
end
+ test "promotions with unicode characters (emoji) are displayed correctly", params do
+ switch = params.switch
+ Support.Stubs.Switch.add_target(switch, name: "Deploy 🚀 Production")
+
+ page = open(params)
+
+ assert_text(page, "Deploy 🚀 Production")
+ end
+
+ test "promotions with accented characters are displayed correctly", params do
+ switch = params.switch
+ Support.Stubs.Switch.add_target(switch, name: "Déploiement Français")
+
+ page = open(params)
+
+ assert_text(page, "Déploiement Français")
+ end
+
+ test "promotions with CJK characters are displayed correctly", params do
+ switch = params.switch
+ Support.Stubs.Switch.add_target(switch, name: "部署到生产环境")
+
+ page = open(params)
+
+ assert_text(page, "部署到生产环境")
+ end
+
+ test "promotions with single quotes are displayed and clickable", params do
+ switch = params.switch
+ Support.Stubs.Switch.add_target(switch, name: "Publish 'my-package' to Production")
+
+ page = open(params)
+
+ assert_text(page, "Publish 'my-package' to Production")
+ assert find(page, Query.button("Publish 'my-package' to Production"))
+ end
+
+ test "promotions with mixed unicode and special characters work correctly", params do
+ switch = params.switch
+ Support.Stubs.Switch.add_target(switch, name: "Deploy 'app' 🎉 to Staging")
+
+ page = open(params)
+
+ assert_text(page, "Deploy 'app' 🎉 to Staging")
+ assert find(page, Query.button("Deploy 'app' 🎉 to Staging"))
+ end
+
test "If project is public, show blocks, but promotions should be disabled", params do
Support.Stubs.PermissionPatrol.remove_all_permissions()
diff --git a/front/test/front_web/views/pipeline_view_test.exs b/front/test/front_web/views/pipeline_view_test.exs
index 0901476c4..dd429849b 100644
--- a/front/test/front_web/views/pipeline_view_test.exs
+++ b/front/test/front_web/views/pipeline_view_test.exs
@@ -1,5 +1,6 @@
defmodule FrontWeb.PipelineViewTest do
use FrontWeb.ConnCase
+ import Phoenix.View, only: [render_to_string: 3]
alias Front.Models
alias FrontWeb.PipelineView
alias Support.Factories
@@ -187,6 +188,121 @@ defmodule FrontWeb.PipelineViewTest do
%{pipeline | triggerer: triggerer}
end
+ describe "switch/_target_form.html" do
+ test "renders promotion attributes correctly when the target name includes single quotes", %{
+ conn: conn
+ } do
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: "Publish 'my-package' to Production", parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target="Publish 'my-package' to Production")
+ assert html =~ ~s(data-switch="sw-1")
+ assert html =~ ~s(promote-confirmation)
+ end
+
+ test "escapes double quotes inside promotion target names", %{conn: conn} do
+ target_name = ~s(Publish "critical" to Production)
+
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: target_name, parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target="Publish "critical" to Production")
+ assert html =~ ~s(Start promotion)
+ end
+
+ test "handles promotion target names with unicode emoji", %{conn: conn} do
+ target_name = "Deploy 🚀 to Production"
+
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: target_name, parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target="Deploy 🚀 to Production")
+ assert html =~ ~s(promote-confirmation)
+ end
+
+ test "handles promotion target names with accented characters", %{conn: conn} do
+ target_name = "Déploiement en Français"
+
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: target_name, parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target="Déploiement en Français")
+ assert html =~ ~s(promote-confirmation)
+ end
+
+ test "handles promotion target names with CJK characters", %{conn: conn} do
+ target_name = "部署到生产环境"
+
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: target_name, parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target="部署到生产环境")
+ assert html =~ ~s(promote-confirmation)
+ end
+
+ test "handles promotion target names with mixed unicode and special characters", %{conn: conn} do
+ target_name = "Deploy 'app' 🎉 to Staging"
+
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: target_name, parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target="Deploy 'app' 🎉 to Staging")
+ assert html =~ ~s(promote-confirmation)
+ end
+
+ test "handles CSS selector metacharacters in target names", %{conn: conn} do
+ target_name = "Deploy[test]:value.class#id"
+
+ html =
+ render_to_string(PipelineView, "switch/_target_form.html", %{
+ conn: conn,
+ workflow: %{id: "wf-1"},
+ pipeline: %{id: "pl-1"},
+ switch: %{id: "sw-1"},
+ target: %{name: target_name, parameters: []}
+ })
+
+ assert html =~ ~s(data-promotion-target=)
+ assert html =~ ~s(promote-confirmation)
+ end
+ end
+
describe ".action_string" do
test "when the pipeline is terminated by a user => shows correct message" do
terminator = %Models.User{