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"); + } }