diff --git a/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java b/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java
index d904297d..82488ab0 100644
--- a/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java
+++ b/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java
@@ -287,6 +287,20 @@ public void addBuildInfoStepUsingConnection(
json);
}
+ /** Sets one property on an existing build step, as saving the step's form would. */
+ public void setStepProperty(
+ final String buildTypeId, final String stepId, final String name, final String value)
+ throws Exception {
+ final String path =
+ "/httpAuth/app/rest/buildTypes/" + buildTypeId + "/steps/" + stepId + "/parameters/" + name;
+ // This endpoint answers in plain text, so it refuses the JSON Accept header send() uses.
+ final Http.Response resp = request("PUT", baseUrl + path, "text/plain", "text/plain", value);
+ if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
+ throw new IllegalStateException(
+ "PUT " + path + " -> " + resp.statusCode() + ": " + resp.body());
+ }
+ }
+
/**
* Adds an empty Create release (octopus.create.release) step; enough for the edit page to render.
* Returns the generated runner id (e.g. {@code RUNNER_1}).
diff --git a/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusSpacePickerUiTest.java b/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusSpacePickerUiTest.java
new file mode 100644
index 00000000..d7048d81
--- /dev/null
+++ b/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusSpacePickerUiTest.java
@@ -0,0 +1,328 @@
+package octopus.teamcity.e2e.test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.options.LoadState;
+import com.microsoft.playwright.options.WaitForSelectorState;
+import com.microsoft.playwright.options.WaitUntilState;
+import octopus.teamcity.e2e.dsl.OctopusTeamCityStack;
+import octopus.teamcity.e2e.dsl.PlaywrightUi;
+import octopus.teamcity.e2e.dsl.SharedStack;
+import octopus.teamcity.e2e.dsl.TeamCityRest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * UI test for the space picker: the space name field suggests the connection's spaces, loaded from
+ * the Octopus container on their own, and choosing one stores the space's id rather than
+ * only its name. Anything else typed - such as a parameter reference - is kept as the name.
+ *
+ *
Storing the id is the whole point of the picker - a name breaks as soon as the space is
+ * renamed in Octopus, whereas the id does not.
+ */
+class OctopusSpacePickerUiTest {
+
+ @Test
+ void suggestsTheConnectionsSpacesAndStoresTheChosenId() throws Exception {
+ try (final OctopusTeamCityStack stack = SharedStack.full()) {
+ final TeamCityRest tc = stack.rest();
+ tc.createProject("SpacePickerIT", "Space picker IT");
+ // A connection with a real URL and key, so the lookup can actually succeed. The TeamCity
+ // server makes the call, so it needs the container-network URL.
+ final String connectionId =
+ tc.createOctopusConnection(
+ "SpacePickerIT",
+ "Real Octopus",
+ stack.octopusUrlForContainers(),
+ stack.octopusApiKey(),
+ "");
+ tc.createBuildType("SpacePickerIT_Release", "Create release", "SpacePickerIT");
+ final String runnerId = tc.addCreateReleaseStep("SpacePickerIT_Release");
+
+ PlaywrightUi.withLoggedInPage(
+ stack,
+ page -> {
+ openStep(page, stack, "SpacePickerIT_Release", runnerId);
+ // No connection yet, so there is nothing to suggest.
+ assertThat(status(page).innerText()).contains("Select a connection");
+ assertNoSpacesListed(page);
+
+ // Choosing the connection loads its spaces without any further click.
+ page.selectOption("#octopusConnectionId", new String[] {connectionId});
+ waitForSpaces(page);
+
+ // A fresh Octopus has the Default space, suggested as " ()".
+ assertThat(suggestions(page)).contains("Default (Spaces-1)");
+ page.locator("ul.ui-autocomplete li", new Page.LocatorOptions().setHasText("Default"))
+ .first()
+ .click();
+
+ assertThat(spaceId(page)).isEqualTo("Spaces-1");
+ assertThat(spaceName(page).inputValue()).isEqualTo("Default");
+ });
+ }
+ }
+
+ @Test
+ void keepsATypedNameAndStoresTheIdOnlyForAnExactMatch() throws Exception {
+ try (final OctopusTeamCityStack stack = SharedStack.full()) {
+ final TeamCityRest tc = stack.rest();
+ tc.createProject("SpacePickerTypedIT", "Space picker typed name IT");
+ final String connectionId =
+ tc.createOctopusConnection(
+ "SpacePickerTypedIT",
+ "Real Octopus",
+ stack.octopusUrlForContainers(),
+ stack.octopusApiKey(),
+ "");
+ tc.createBuildType("SpacePickerTypedIT_Release", "Create release", "SpacePickerTypedIT");
+ final String runnerId = tc.addCreateReleaseStep("SpacePickerTypedIT_Release");
+ tc.setStepProperty(
+ "SpacePickerTypedIT_Release", runnerId, "octopus_connection_id", connectionId);
+
+ PlaywrightUi.withLoggedInPage(
+ stack,
+ page -> {
+ openStep(page, stack, "SpacePickerTypedIT_Release", runnerId);
+ waitForSpaces(page);
+
+ // Typing narrows the suggestions to spaces whose name contains the text.
+ spaceName(page).fill("");
+ spaceName(page).pressSequentially("defa");
+ assertThat(suggestions(page)).containsExactly("Default (Spaces-1)");
+
+ // An exact name, in any case, picks the space.
+ spaceName(page).fill("default");
+ assertThat(spaceId(page)).isEqualTo("Spaces-1");
+
+ // After a %, TeamCity's parameters are suggested instead, and choosing one completes
+ // the reference.
+ spaceName(page).fill("");
+ spaceName(page).pressSequentially("%build.num");
+ page.locator(
+ "ul.ui-autocomplete:visible li",
+ new Page.LocatorOptions().setHasText("build.number"))
+ .first()
+ .click();
+ assertThat(spaceName(page).inputValue()).isEqualTo("%build.number%");
+ assertThat(spaceId(page)).isEmpty();
+
+ // A parameter reference is resolved when the build runs, so it is kept as the name and
+ // not checked against Octopus.
+ spaceName(page).fill("%octopus.space%");
+ spaceName(page).blur();
+ assertThat(spaceId(page)).isEmpty();
+ assertThat(missingSpace(page).isVisible()).isFalse();
+
+ // Any other name is kept as typed, and flagged.
+ spaceName(page).fill("Nowhere");
+ spaceName(page).blur();
+ assertThat(spaceId(page)).isEmpty();
+ assertThat(missingSpace(page).innerText())
+ .isEqualTo("No space named 'Nowhere' was found in Octopus.");
+ });
+ }
+ }
+
+ /**
+ * The parameter completion comes from TeamCity internals the picker calls into. If those change,
+ * the field must still suggest spaces and accept whatever is typed.
+ */
+ @Test
+ void stillSuggestsSpacesWhenTeamCitysParameterCompletionFails() throws Exception {
+ try (final OctopusTeamCityStack stack = SharedStack.full()) {
+ final TeamCityRest tc = stack.rest();
+ tc.createProject("SpacePickerBrokenIT", "Space picker broken completion IT");
+ final String connectionId =
+ tc.createOctopusConnection(
+ "SpacePickerBrokenIT",
+ "Real Octopus",
+ stack.octopusUrlForContainers(),
+ stack.octopusApiKey(),
+ "");
+ tc.createBuildType("SpacePickerBrokenIT_Release", "Create release", "SpacePickerBrokenIT");
+ final String runnerId = tc.addCreateReleaseStep("SpacePickerBrokenIT_Release");
+ tc.setStepProperty(
+ "SpacePickerBrokenIT_Release", runnerId, "octopus_connection_id", connectionId);
+
+ PlaywrightUi.withLoggedInPage(
+ stack,
+ page -> {
+ final List pageErrors = new java.util.ArrayList<>();
+ page.onPageError(pageErrors::add);
+ openStep(page, stack, "SpacePickerBrokenIT_Release", runnerId);
+ waitForSpaces(page);
+ page.evaluate(
+ "() => { BS.AvailableParams.sliceForCompletion = function () {"
+ + " throw new Error('changed in this TeamCity version'); };"
+ + " BS.AvailableParams.sourceForElement = undefined; }");
+
+ spaceName(page).fill("");
+ spaceName(page).pressSequentially("%octo");
+ spaceName(page).fill("");
+ spaceName(page).pressSequentially("Def");
+ assertThat(suggestions(page)).containsExactly("Default (Spaces-1)");
+ spaceName(page).fill("%octopus.space%");
+ assertThat(spaceName(page).inputValue()).isEqualTo("%octopus.space%");
+ assertThat(pageErrors).isEmpty();
+ });
+ }
+ }
+
+ @Test
+ void flagsASavedSpaceThatOctopusDoesNotHave() throws Exception {
+ try (final OctopusTeamCityStack stack = SharedStack.full()) {
+ final TeamCityRest tc = stack.rest();
+ tc.createProject("SpacePickerMissingIT", "Space picker missing space IT");
+ final String connectionId =
+ tc.createOctopusConnection(
+ "SpacePickerMissingIT",
+ "Real Octopus",
+ stack.octopusUrlForContainers(),
+ stack.octopusApiKey(),
+ "");
+ tc.createBuildType("SpacePickerMissingIT_Release", "Create release", "SpacePickerMissingIT");
+ final String runnerId = tc.addCreateReleaseStep("SpacePickerMissingIT_Release");
+ tc.setStepProperty(
+ "SpacePickerMissingIT_Release", runnerId, "octopus_connection_id", connectionId);
+ tc.setStepProperty(
+ "SpacePickerMissingIT_Release", runnerId, "octopus_space_id", "Spaces-999");
+ tc.setStepProperty("SpacePickerMissingIT_Release", runnerId, "octopus_space_name", "Gone");
+
+ PlaywrightUi.withLoggedInPage(
+ stack,
+ page -> {
+ openStep(page, stack, "SpacePickerMissingIT_Release", runnerId);
+ waitForSpaces(page);
+
+ assertThat(missingSpace(page).innerText())
+ .isEqualTo(
+ "The saved space 'Gone' (Spaces-999) was not found. Please enter the space"
+ + " name.");
+ // The saved setting is left alone until someone changes it.
+ assertThat(spaceId(page)).isEqualTo("Spaces-999");
+ assertThat(spaceName(page).inputValue()).isEqualTo("Gone");
+ });
+ }
+ }
+
+ @Test
+ void switchingConnectionDropsTheSavedSpaceId() throws Exception {
+ try (final OctopusTeamCityStack stack = SharedStack.full()) {
+ final TeamCityRest tc = stack.rest();
+ tc.createProject("SpacePickerSwitchIT", "Space picker connection switch IT");
+ final String connectionId =
+ tc.createOctopusConnection(
+ "SpacePickerSwitchIT",
+ "Real Octopus",
+ stack.octopusUrlForContainers(),
+ stack.octopusApiKey(),
+ "");
+ tc.createBuildType("SpacePickerSwitchIT_Release", "Create release", "SpacePickerSwitchIT");
+ final String runnerId = tc.addCreateReleaseStep("SpacePickerSwitchIT_Release");
+ tc.setStepProperty(
+ "SpacePickerSwitchIT_Release", runnerId, "octopus_connection_id", connectionId);
+ tc.setStepProperty("SpacePickerSwitchIT_Release", runnerId, "octopus_space_id", "Spaces-1");
+ tc.setStepProperty("SpacePickerSwitchIT_Release", runnerId, "octopus_space_name", "Default");
+
+ PlaywrightUi.withLoggedInPage(
+ stack,
+ page -> {
+ openStep(page, stack, "SpacePickerSwitchIT_Release", runnerId);
+ waitForSpaces(page);
+ assertThat(spaceId(page)).isEqualTo("Spaces-1");
+
+ page.selectOption("#octopusConnectionId", new String[] {""});
+
+ assertThat(spaceId(page)).isEmpty();
+ assertThat(spaceName(page).inputValue()).isEqualTo("Default");
+ });
+ }
+ }
+
+ @Test
+ void connectionFormSuggestsTheConnectionsOwnSpaces() throws Exception {
+ try (final OctopusTeamCityStack stack = SharedStack.full()) {
+ final TeamCityRest tc = stack.rest();
+ tc.createProject("SpacePickerConnFormIT", "Space picker connection form IT");
+ tc.createOctopusConnection(
+ "SpacePickerConnFormIT",
+ "Picker Octopus",
+ stack.octopusUrlForContainers(),
+ stack.octopusApiKey(),
+ "");
+
+ PlaywrightUi.withLoggedInPage(
+ stack,
+ page -> {
+ page.navigate(
+ stack.tcBaseUrl()
+ + "/admin/editProject.html?projectId=SpacePickerConnFormIT"
+ + "&tab=oauthConnections");
+ page.waitForLoadState(LoadState.NETWORKIDLE);
+ page.locator("tr", new Page.LocatorOptions().setHasText("Picker Octopus"))
+ .locator("a:has-text(\"Edit\")")
+ .first()
+ .click();
+
+ waitForSpaces(page);
+ assertThat(suggestions(page)).contains("Default (Spaces-1)");
+ });
+ }
+ }
+
+ private static void openStep(
+ final Page page,
+ final OctopusTeamCityStack stack,
+ final String buildTypeId,
+ final String runnerId) {
+ page.navigate(
+ stack.tcBaseUrl()
+ + "/admin/editRunType.html?id=buildType:"
+ + buildTypeId
+ + "&runnerId="
+ + runnerId,
+ new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
+ page.waitForLoadState(LoadState.NETWORKIDLE);
+ }
+
+ private static Locator status(final Page page) {
+ return page.locator("span.octopusSpaceStatus").first();
+ }
+
+ private static Locator spaceName(final Page page) {
+ return page.locator("#octopus_space_name").first();
+ }
+
+ private static String spaceId(final Page page) {
+ return page.locator("input[name='prop:octopus_space_id']").first().inputValue();
+ }
+
+ private static Locator missingSpace(final Page page) {
+ return page.locator("span.octopusSpaceMissing").first();
+ }
+
+ /** The by-id note is shown exactly when the connection's spaces have been listed. */
+ private static void waitForSpaces(final Page page) {
+ page.locator("span.octopusSpaceByIdNote")
+ .first()
+ .waitFor(
+ new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE).setTimeout(60000));
+ }
+
+ private static void assertNoSpacesListed(final Page page) {
+ assertThat(spaceName(page).isVisible()).isTrue();
+ assertThat(page.locator("span.octopusSpaceByIdNote").first().isVisible()).isFalse();
+ }
+
+ /** What the space name field suggests for what it currently holds. */
+ private static List suggestions(final Page page) {
+ spaceName(page).focus();
+ final Locator items = page.locator("ul.ui-autocomplete:visible li");
+ items.first().waitFor(new Locator.WaitForOptions().setTimeout(10000));
+ return items.allInnerTexts();
+ }
+}
diff --git a/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionUiData.java b/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionUiData.java
index 7d156bcd..0801199d 100644
--- a/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionUiData.java
+++ b/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionUiData.java
@@ -28,6 +28,7 @@
import jetbrains.buildServer.serverSide.SProject;
import jetbrains.buildServer.serverSide.oauth.OAuthConnectionDescriptor;
import jetbrains.buildServer.users.SUser;
+import jetbrains.buildServer.web.openapi.PluginDescriptor;
import jetbrains.buildServer.web.util.SessionUser;
import octopus.teamcity.common.connection.ConnectionPropertyNames;
import org.jetbrains.annotations.NotNull;
@@ -46,13 +47,17 @@ public class OctopusConnectionUiData {
private static volatile OctopusConnectionsManager connectionsManager;
private static volatile ProjectManager projectManager;
+ private static volatile PluginDescriptor pluginDescriptor;
@SuppressWarnings(
"StaticAssignmentInConstructor") // JSPs have no Spring context; capture statically.
public OctopusConnectionUiData(
- final OctopusConnectionsManager connectionsManager, final ProjectManager projectManager) {
+ final OctopusConnectionsManager connectionsManager,
+ final ProjectManager projectManager,
+ final PluginDescriptor pluginDescriptor) {
OctopusConnectionUiData.connectionsManager = connectionsManager;
OctopusConnectionUiData.projectManager = projectManager;
+ OctopusConnectionUiData.pluginDescriptor = pluginDescriptor;
}
/**
@@ -170,6 +175,37 @@ public static String buildFeaturesUrl(final HttpServletRequest request) {
+ buildType.getExternalId();
}
+ /**
+ * The project whose form is being edited, for the space picker's permission check.
+ *
+ * A connection page carries {@code projectId} directly; a step page instead carries {@code
+ * id=buildType:...}, so the project is derived from the build type.
+ */
+ @NotNull
+ public static String spacePickerProjectId(final HttpServletRequest request) {
+ final String projectId = request.getParameter("projectId");
+ if (projectId != null && !projectId.trim().isEmpty()) {
+ return projectId.trim();
+ }
+ final String fromBuildType = currentProjectExternalId(request);
+ return fromBuildType == null ? "" : fromBuildType;
+ }
+
+ /**
+ * Context-relative URL of the space picker's script. The plugin version is appended so a browser
+ * does not keep running a cached copy after the plugin is upgraded.
+ */
+ @NotNull
+ public static String spacePickerScriptUrl(final HttpServletRequest request) {
+ if (pluginDescriptor == null) {
+ return "";
+ }
+ final String version = pluginDescriptor.getPluginVersion();
+ return request.getContextPath()
+ + pluginDescriptor.getPluginResourcesPath("js/spacePicker.js")
+ + (version == null ? "" : "?v=" + version);
+ }
+
private static String currentProjectExternalId(final HttpServletRequest request) {
final SBuildType buildType = currentBuildType(request);
return buildType == null ? null : buildType.getProject().getExternalId();
diff --git a/octopus-server/src/main/resources/buildServerResources/editOctopusConnection.jsp b/octopus-server/src/main/resources/buildServerResources/editOctopusConnection.jsp
index 27da8efe..4ad0ca06 100644
--- a/octopus-server/src/main/resources/buildServerResources/editOctopusConnection.jsp
+++ b/octopus-server/src/main/resources/buildServerResources/editOctopusConnection.jsp
@@ -93,6 +93,7 @@
+
diff --git a/octopus-server/src/test/java/octopus/teamcity/server/connection/OctopusConnectionUiDataTest.java b/octopus-server/src/test/java/octopus/teamcity/server/connection/OctopusConnectionUiDataTest.java
index 01797905..42e5d63c 100644
--- a/octopus-server/src/test/java/octopus/teamcity/server/connection/OctopusConnectionUiDataTest.java
+++ b/octopus-server/src/test/java/octopus/teamcity/server/connection/OctopusConnectionUiDataTest.java
@@ -1,6 +1,7 @@
package octopus.teamcity.server.connection;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -17,6 +18,7 @@
import jetbrains.buildServer.serverSide.SProject;
import jetbrains.buildServer.serverSide.oauth.OAuthConnectionDescriptor;
import jetbrains.buildServer.users.SUser;
+import jetbrains.buildServer.web.openapi.PluginDescriptor;
import octopus.teamcity.common.connection.ConnectionPropertyNames;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -29,12 +31,13 @@ class OctopusConnectionUiDataTest {
@Mock private OctopusConnectionsManager connectionsManager;
@Mock private ProjectManager projectManager;
+ @Mock private PluginDescriptor pluginDescriptor;
@Mock private SUser user;
@BeforeEach
void setUp() {
// Constructing the bean captures the collaborators into the class's static fields.
- new OctopusConnectionUiData(connectionsManager, projectManager);
+ new OctopusConnectionUiData(connectionsManager, projectManager, pluginDescriptor);
}
private OAuthConnectionDescriptor connection(
@@ -247,4 +250,46 @@ void buildFeaturesUrlEmptyWhenUnresolved() {
assertThat(OctopusConnectionUiData.buildFeaturesUrl(request)).isEqualTo("");
}
+
+ private static HttpServletRequest requestWith(final Map parameters) {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getParameter(anyString()))
+ .thenAnswer(invocation -> parameters.get(invocation.getArgument(0)));
+ return request;
+ }
+
+ @Test
+ void spacePickerScriptUrlIsContextRelativeAndVersioned() {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getContextPath()).thenReturn("/tc");
+ when(pluginDescriptor.getPluginResourcesPath("js/spacePicker.js"))
+ .thenReturn("/plugins/Octopus.TeamCity/js/spacePicker.js");
+ when(pluginDescriptor.getPluginVersion()).thenReturn("6.2.0");
+
+ assertThat(OctopusConnectionUiData.spacePickerScriptUrl(request))
+ .isEqualTo("/tc/plugins/Octopus.TeamCity/js/spacePicker.js?v=6.2.0");
+ }
+
+ @Test
+ void spacePickerProjectIdIsTheConnectionFormsProject() {
+ final Map parameters = new HashMap<>();
+ parameters.put("projectId", " Rtest ");
+
+ assertThat(OctopusConnectionUiData.spacePickerProjectId(requestWith(parameters)))
+ .isEqualTo("Rtest");
+ }
+
+ @Test
+ void spacePickerProjectIdIsTheStepFormsBuildConfigurationsProject() {
+ final SBuildType buildType = mock(SBuildType.class);
+ final SProject project = mock(SProject.class);
+ when(projectManager.findBuildTypeByExternalId("Rtest_Build")).thenReturn(buildType);
+ when(buildType.getProject()).thenReturn(project);
+ when(project.getExternalId()).thenReturn("Rtest");
+ final Map parameters = new HashMap<>();
+ parameters.put("id", "buildType:Rtest_Build");
+
+ assertThat(OctopusConnectionUiData.spacePickerProjectId(requestWith(parameters)))
+ .isEqualTo("Rtest");
+ }
}