Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/portal-ui-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#
# Copyright 2026 Apollo Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

name: portal-ui-e2e

on:
pull_request:
branches: [ master ]
paths:
- 'apollo-portal/**'
- 'apollo-assembly/**'
- 'e2e/portal-e2e/**'
- 'scripts/sql/**'
- '.github/workflows/portal-ui-e2e.yml'

jobs:
portal-ui-e2e:
name: portal-ui-e2e
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: maven

- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/portal-e2e/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-

- name: Build Apollo assembly
run: ./mvnw -B -pl apollo-assembly -am -DskipTests package

- name: Start Apollo assembly
run: |
JAR=$(ls -1 apollo-assembly/target/apollo-assembly-*.jar | grep -vE 'sources|javadoc' | head -n 1)
SPRING_PROFILES_ACTIVE="github,database-discovery,auth" \
SPRING_SQL_CONFIG_INIT_MODE="always" \
SPRING_SQL_PORTAL_INIT_MODE="always" \
SPRING_CONFIG_DATASOURCE_URL="jdbc:h2:mem:apollo-config-db;mode=mysql;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1;BUILTIN_ALIAS_OVERRIDE=TRUE;DATABASE_TO_UPPER=FALSE" \
SPRING_PORTAL_DATASOURCE_URL="jdbc:h2:mem:apollo-portal-db;mode=mysql;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1;BUILTIN_ALIAS_OVERRIDE=TRUE;DATABASE_TO_UPPER=FALSE" \
java -jar "$JAR" > /tmp/apollo-assembly-run.log 2>&1 &
echo $! > /tmp/apollo-assembly.pid
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Wait for Apollo readiness
run: ./e2e/portal-e2e/scripts/wait-for-ready.sh

- name: Run Playwright UI tests
env:
BASE_URL: http://127.0.0.1:8070
PLAYWRIGHT_WORKERS: 2
run: |
cd e2e/portal-e2e
npm ci
npx playwright install chromium
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI workflow installs Playwright’s Chromium browser without OS dependencies. On ubuntu runners this can lead to runtime failures (missing libraries), and it’s also inconsistent with the local-run docs which use --with-deps. Consider using the --with-deps chromium install mode (or the official Playwright GitHub Action) to reduce flakiness.

Suggested change
npx playwright install chromium
npx playwright install --with-deps chromium

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. We are intentionally not applying this in this PR: CI has been stable and we chose to keep the current install mode for lower runtime. If dependency-related flakes appear, we will switch back to --with-deps.

npm run test:e2e:ci
Comment on lines +91 to +95
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add --with-deps to playwright install chromium for CI.

ubuntu-latest runners may not have all Chromium system dependencies pre-installed. Without --with-deps, the browser launch can fail with missing shared-library errors. The local run instructions in e2e/README.md (line 62) already include --with-deps.

Suggested fix
-          npx playwright install chromium
+          npx playwright install --with-deps chromium
🤖 Prompt for AI Agents
In @.github/workflows/portal-ui-e2e.yml around lines 78 - 82, Update the CI step
that calls the Playwright installer: change the command invocation "npx
playwright install chromium" in the run block to include the --with-deps flag
(i.e., "npx playwright install --with-deps chromium") so the installer also
installs required system dependencies for Chromium.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. We are intentionally not applying this in this PR: CI has been stable and we chose to keep the current install mode for lower runtime. If dependency-related flakes appear, we will switch back to --with-deps.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nobodyiam, understood! Since your CI has been stable without the flag and you're optimizing for faster runs, keeping the current approach makes sense. If you do encounter any browser launch issues later, the --with-deps flag will be there as a fallback. Thanks for the clarification!


- name: Upload e2e test artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: portal-ui-e2e-artifacts
path: |
e2e/portal-e2e/playwright-report
e2e/portal-e2e/test-results
/tmp/apollo-assembly-run.log

- name: Stop Apollo assembly
if: always()
run: |
if [ -f /tmp/apollo-assembly.pid ]; then
kill "$(cat /tmp/apollo-assembly.pid)" || true
fi
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
## Commit & Pull Request Guidelines
- Use Conventional Commits format (e.g., `feat:`, `fix:`).
- If a commit fixes an issue, append `Fixes #123` in the commit message.
- Commit only on feature branches; never commit directly to `master` or `main`.
- `CHANGES.md` entries must use a PR URL in Markdown link format; if the PR URL is not available yet, open the PR first, then add/update `CHANGES.md` in a follow-up commit.
- Rebase onto `master` and squash feature work into a single commit before merge.
- When merging a PR on GitHub: if it has a single commit, use rebase and merge; if it has multiple commits, use squash and merge.
- Non-trivial contributions require signing the CLA.
Expand Down
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ Apollo 2.5.0
* [Perf: Replace synchronized multimap with concurrent hashmap in NotificationControllerV2 for better performance](https://github.com/apolloconfig/apollo/pull/5532)
* [Feature: Enable graceful shutdown for apollo-adminservice and apollo-configservice](https://github.com/apolloconfig/apollo/pull/5536)
* [Feature: Support search box and fullscreen in namespace text editor](https://github.com/apolloconfig/apollo/pull/5545)
* [CI: Add portal UI Playwright e2e gate on PRs with JDK 17](https://github.com/apolloconfig/apollo/pull/5551)
------------------
All issues and pull requests are [here](https://github.com/apolloconfig/apollo/milestone/16?closed=1)
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2025 Apollo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.ctrip.framework.apollo.portal.controller;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.ctrip.framework.apollo.common.dto.ClusterDTO;
import com.ctrip.framework.apollo.portal.entity.bo.UserInfo;
import com.ctrip.framework.apollo.portal.environment.Env;
import com.ctrip.framework.apollo.portal.service.ClusterService;
import com.ctrip.framework.apollo.portal.spi.UserInfoHolder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.http.ResponseEntity;

@RunWith(MockitoJUnitRunner.class)
public class ClusterControllerTest {

@Mock
private ClusterService clusterService;

@Mock
private UserInfoHolder userInfoHolder;

@InjectMocks
private ClusterController clusterController;

@Captor
private ArgumentCaptor<ClusterDTO> clusterCaptor;

@Test
public void shouldCreateClusterWithCurrentOperator() {
ClusterDTO toCreate = new ClusterDTO();
toCreate.setAppId("SampleApp");
toCreate.setName("sampleCluster");

ClusterDTO created = new ClusterDTO();
created.setAppId("SampleApp");
created.setName("sampleCluster");

when(userInfoHolder.getUser()).thenReturn(new UserInfo("apollo"));
when(clusterService.createCluster(eq(Env.DEV), clusterCaptor.capture())).thenReturn(created);

ClusterDTO result = clusterController.createCluster("SampleApp", "DEV", toCreate);

assertSame(created, result);
ClusterDTO captured = clusterCaptor.getValue();
assertEquals("apollo", captured.getDataChangeCreatedBy());
assertEquals("apollo", captured.getDataChangeLastModifiedBy());
}

@Test
public void shouldDeleteClusterByEnvAndName() {
ResponseEntity<Void> response =
clusterController.deleteCluster("SampleApp", "DEV", "sampleCluster");

assertEquals(200, response.getStatusCodeValue());
verify(clusterService).deleteCluster(Env.DEV, "SampleApp", "sampleCluster");
}

@Test
public void shouldLoadClusterFromService() {
ClusterDTO loaded = new ClusterDTO();
loaded.setName("sampleCluster");
when(clusterService.loadCluster("SampleApp", Env.DEV, "sampleCluster")).thenReturn(loaded);

ClusterDTO result = clusterController.loadCluster("SampleApp", "DEV", "sampleCluster");

assertSame(loaded, result);
verify(clusterService).loadCluster("SampleApp", Env.DEV, "sampleCluster");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* Copyright 2025 Apollo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.ctrip.framework.apollo.portal.controller;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.ctrip.framework.apollo.common.dto.ItemDTO;
import com.ctrip.framework.apollo.common.exception.ServiceException;
import com.ctrip.framework.apollo.portal.entity.bo.ItemBO;
import com.ctrip.framework.apollo.portal.entity.bo.NamespaceBO;
import com.ctrip.framework.apollo.portal.environment.Env;
import com.ctrip.framework.apollo.portal.service.ConfigsExportService;
import com.ctrip.framework.apollo.portal.service.NamespaceService;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Collections;
import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

@RunWith(MockitoJUnitRunner.class)
public class ConfigsExportControllerTest {

@Mock
private ConfigsExportService configsExportService;

@Mock
private NamespaceService namespaceService;

@InjectMocks
private ConfigsExportController configsExportController;

@Test
public void shouldExportNamespaceWithPropertiesSuffixWhenMissing() {
NamespaceBO namespace = new NamespaceBO();
namespace.setFormat("properties");
namespace.setItems(Collections.singletonList(itemBO("timeout", "100")));

when(namespaceService.loadNamespaceBO("SampleApp", Env.DEV, "default", "application", true,
false)).thenReturn(namespace);

MockHttpServletResponse response = new MockHttpServletResponse();

configsExportController.exportItems("SampleApp", "DEV", "default", "application", response);

assertEquals("attachment;filename=application.properties",
response.getHeader(HttpHeaders.CONTENT_DISPOSITION));
assertTrue(new String(response.getContentAsByteArray()).contains("\"key\":\"timeout\""));
}

@Test
public void shouldExportNamespaceKeepOriginalSuffixWhenFormatIsValid() {
NamespaceBO namespace = new NamespaceBO();
namespace.setFormat("yml");
namespace.setItems(Collections.singletonList(itemBO("content", "a: b")));

when(namespaceService.loadNamespaceBO("SampleApp", Env.DEV, "default", "application.yml", true,
false)).thenReturn(namespace);

MockHttpServletResponse response = new MockHttpServletResponse();

configsExportController.exportItems("SampleApp", "DEV", "default", "application.yml", response);

assertEquals("attachment;filename=application.yml",
response.getHeader(HttpHeaders.CONTENT_DISPOSITION));
assertTrue(new String(response.getContentAsByteArray()).contains("\"key\":\"content\""));
}

@Test(expected = ServiceException.class)
public void shouldWrapExportNamespaceIOExceptionAsServiceException() throws IOException {
NamespaceBO namespace = new NamespaceBO();
namespace.setFormat("properties");
namespace.setItems(Collections.singletonList(itemBO("timeout", "100")));

when(namespaceService.loadNamespaceBO("SampleApp", Env.DEV, "default", "application", true,
false)).thenReturn(namespace);

HttpServletResponse response = org.mockito.Mockito.mock(HttpServletResponse.class);
when(response.getOutputStream()).thenReturn(new FailingServletOutputStream());

configsExportController.exportItems("SampleApp", "DEV", "default", "application", response);
}

@Test
public void shouldExportAllConfigsWithParsedEnvs() throws IOException {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr("127.0.0.1");
request.setRemoteHost("localhost");
MockHttpServletResponse response = new MockHttpServletResponse();

doAnswer(invocation -> {
OutputStream outputStream = invocation.getArgument(0);
outputStream.write("ok".getBytes());
return null;
}).when(configsExportService).exportData(any(OutputStream.class),
eq(Arrays.asList(Env.DEV, Env.FAT)));

configsExportController.exportAll("DEV,FAT", request, response);

verify(configsExportService).exportData(any(OutputStream.class),
eq(Arrays.asList(Env.DEV, Env.FAT)));
assertTrue(response.getHeader(HttpHeaders.CONTENT_DISPOSITION)
.startsWith("attachment;filename=apollo_config_export_"));
assertEquals("ok", response.getContentAsString());
}

@Test
public void shouldExportAppConfigByEnvAndCluster() throws IOException {
HttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();

doAnswer(invocation -> {
OutputStream outputStream = invocation.getArgument(3);
outputStream.write("app".getBytes());
return null;
}).when(configsExportService).exportAppConfigByEnvAndCluster(eq("SampleApp"), eq(Env.DEV),
eq("default"), any(OutputStream.class));

configsExportController.exportAppConfig("SampleApp", "DEV", "default", request, response);

verify(configsExportService).exportAppConfigByEnvAndCluster(eq("SampleApp"), eq(Env.DEV),
eq("default"), any(OutputStream.class));
assertTrue(response.getHeader(HttpHeaders.CONTENT_DISPOSITION)
.startsWith("attachment;filename=SampleApp+DEV+default+"));
assertEquals("app", response.getContentAsString());
}

private ItemBO itemBO(String key, String value) {
ItemDTO itemDTO = new ItemDTO();
itemDTO.setKey(key);
itemDTO.setValue(value);
ItemBO itemBO = new ItemBO();
itemBO.setItem(itemDTO);
return itemBO;
}

private static class FailingServletOutputStream extends ServletOutputStream {

@Override
public void write(int b) throws IOException {
throw new IOException("forced write failure");
}

@Override
public boolean isReady() {
return true;
}

@Override
public void setWriteListener(WriteListener writeListener) {
// no-op
}
}
}
Loading
Loading