Skip to content

Commit d4b8e88

Browse files
committed
test(portal-ui): add portal ui e2e gate and optimize ci
- add portal-ui-e2e workflow and Playwright e2e suite\n- expand portal UI-facing controller test coverage\n- add config service full-chain e2e scenarios and docs\n- fix license headers and add PR-linked CHANGES entry\n- optimize e2e runtime via Playwright cache and CI parallel workers\n- clarify AGENTS rule for CHANGES.md PR-link requirement
1 parent 5c930f9 commit d4b8e88

27 files changed

+3585
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#
2+
# Copyright 2026 Apollo Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
name: portal-ui-e2e
18+
19+
on:
20+
pull_request:
21+
branches: [ master ]
22+
paths:
23+
- 'apollo-portal/**'
24+
- 'apollo-assembly/**'
25+
- 'e2e/portal-e2e/**'
26+
- 'scripts/sql/**'
27+
- '.github/workflows/portal-ui-e2e.yml'
28+
29+
jobs:
30+
portal-ui-e2e:
31+
name: portal-ui-e2e
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 90
34+
steps:
35+
- uses: actions/checkout@v4
36+
37+
- name: Set up JDK 17
38+
uses: actions/setup-java@v4
39+
with:
40+
distribution: temurin
41+
java-version: 17
42+
cache: maven
43+
44+
- name: Set up Node.js 20
45+
uses: actions/setup-node@v4
46+
with:
47+
node-version: 20
48+
49+
- name: Cache Playwright browsers
50+
uses: actions/cache@v4
51+
with:
52+
path: ~/.cache/ms-playwright
53+
key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/portal-e2e/package-lock.json') }}
54+
restore-keys: |
55+
${{ runner.os }}-playwright-
56+
57+
- name: Build Apollo assembly
58+
run: ./mvnw -B -pl apollo-assembly -am -DskipTests package
59+
60+
- name: Start Apollo assembly
61+
run: |
62+
JAR=$(ls -1 apollo-assembly/target/apollo-assembly-*.jar | grep -vE 'sources|javadoc' | head -n 1)
63+
SPRING_PROFILES_ACTIVE="github,database-discovery,auth" \
64+
SPRING_SQL_CONFIG_INIT_MODE="always" \
65+
SPRING_SQL_PORTAL_INIT_MODE="always" \
66+
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" \
67+
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" \
68+
java -jar "$JAR" > /tmp/apollo-assembly-run.log 2>&1 &
69+
echo $! > /tmp/apollo-assembly.pid
70+
71+
- name: Wait for Apollo readiness
72+
run: ./e2e/portal-e2e/scripts/wait-for-ready.sh
73+
74+
- name: Run Playwright UI tests
75+
env:
76+
BASE_URL: http://127.0.0.1:8070
77+
PLAYWRIGHT_WORKERS: 2
78+
run: |
79+
cd e2e/portal-e2e
80+
npm ci
81+
npx playwright install chromium
82+
npm run test:e2e:ci
83+
84+
- name: Upload e2e test artifacts on failure
85+
if: failure()
86+
uses: actions/upload-artifact@v4
87+
with:
88+
name: portal-ui-e2e-artifacts
89+
path: |
90+
e2e/portal-e2e/playwright-report
91+
e2e/portal-e2e/test-results
92+
/tmp/apollo-assembly-run.log
93+
94+
- name: Stop Apollo assembly
95+
if: always()
96+
run: |
97+
if [ -f /tmp/apollo-assembly.pid ]; then
98+
kill "$(cat /tmp/apollo-assembly.pid)" || true
99+
fi

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
## Commit & Pull Request Guidelines
3030
- Use Conventional Commits format (e.g., `feat:`, `fix:`).
3131
- If a commit fixes an issue, append `Fixes #123` in the commit message.
32+
- Commit only on feature branches; never commit directly to `master` or `main`.
33+
- `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.
3234
- Rebase onto `master` and squash feature work into a single commit before merge.
3335
- 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.
3436
- Non-trivial contributions require signing the CLA.

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ Apollo 2.5.0
3030
* [Perf: Replace synchronized multimap with concurrent hashmap in NotificationControllerV2 for better performance](https://github.com/apolloconfig/apollo/pull/5532)
3131
* [Feature: Enable graceful shutdown for apollo-adminservice and apollo-configservice](https://github.com/apolloconfig/apollo/pull/5536)
3232
* [Feature: Support search box and fullscreen in namespace text editor](https://github.com/apolloconfig/apollo/pull/5545)
33+
* [CI: Add portal UI Playwright e2e gate on PRs with JDK 17](https://github.com/apolloconfig/apollo/pull/5551)
3334
------------------
3435
All issues and pull requests are [here](https://github.com/apolloconfig/apollo/milestone/16?closed=1)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2025 Apollo Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
package com.ctrip.framework.apollo.portal.controller;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertSame;
21+
import static org.mockito.ArgumentMatchers.eq;
22+
import static org.mockito.Mockito.verify;
23+
import static org.mockito.Mockito.when;
24+
25+
import com.ctrip.framework.apollo.common.dto.ClusterDTO;
26+
import com.ctrip.framework.apollo.portal.entity.bo.UserInfo;
27+
import com.ctrip.framework.apollo.portal.environment.Env;
28+
import com.ctrip.framework.apollo.portal.service.ClusterService;
29+
import com.ctrip.framework.apollo.portal.spi.UserInfoHolder;
30+
import org.junit.Test;
31+
import org.junit.runner.RunWith;
32+
import org.mockito.ArgumentCaptor;
33+
import org.mockito.Captor;
34+
import org.mockito.InjectMocks;
35+
import org.mockito.Mock;
36+
import org.mockito.junit.MockitoJUnitRunner;
37+
import org.springframework.http.ResponseEntity;
38+
39+
@RunWith(MockitoJUnitRunner.class)
40+
public class ClusterControllerTest {
41+
42+
@Mock
43+
private ClusterService clusterService;
44+
45+
@Mock
46+
private UserInfoHolder userInfoHolder;
47+
48+
@InjectMocks
49+
private ClusterController clusterController;
50+
51+
@Captor
52+
private ArgumentCaptor<ClusterDTO> clusterCaptor;
53+
54+
@Test
55+
public void shouldCreateClusterWithCurrentOperator() {
56+
ClusterDTO toCreate = new ClusterDTO();
57+
toCreate.setAppId("SampleApp");
58+
toCreate.setName("sampleCluster");
59+
60+
ClusterDTO created = new ClusterDTO();
61+
created.setAppId("SampleApp");
62+
created.setName("sampleCluster");
63+
64+
when(userInfoHolder.getUser()).thenReturn(new UserInfo("apollo"));
65+
when(clusterService.createCluster(eq(Env.DEV), clusterCaptor.capture())).thenReturn(created);
66+
67+
ClusterDTO result = clusterController.createCluster("SampleApp", "DEV", toCreate);
68+
69+
assertSame(created, result);
70+
ClusterDTO captured = clusterCaptor.getValue();
71+
assertEquals("apollo", captured.getDataChangeCreatedBy());
72+
assertEquals("apollo", captured.getDataChangeLastModifiedBy());
73+
}
74+
75+
@Test
76+
public void shouldDeleteClusterByEnvAndName() {
77+
ResponseEntity<Void> response =
78+
clusterController.deleteCluster("SampleApp", "DEV", "sampleCluster");
79+
80+
assertEquals(200, response.getStatusCodeValue());
81+
verify(clusterService).deleteCluster(Env.DEV, "SampleApp", "sampleCluster");
82+
}
83+
84+
@Test
85+
public void shouldLoadClusterFromService() {
86+
ClusterDTO loaded = new ClusterDTO();
87+
loaded.setName("sampleCluster");
88+
when(clusterService.loadCluster("SampleApp", Env.DEV, "sampleCluster")).thenReturn(loaded);
89+
90+
ClusterDTO result = clusterController.loadCluster("SampleApp", "DEV", "sampleCluster");
91+
92+
assertSame(loaded, result);
93+
verify(clusterService).loadCluster("SampleApp", Env.DEV, "sampleCluster");
94+
}
95+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*
2+
* Copyright 2025 Apollo Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
package com.ctrip.framework.apollo.portal.controller;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertTrue;
21+
import static org.mockito.ArgumentMatchers.any;
22+
import static org.mockito.ArgumentMatchers.eq;
23+
import static org.mockito.Mockito.doAnswer;
24+
import static org.mockito.Mockito.verify;
25+
import static org.mockito.Mockito.when;
26+
27+
import com.ctrip.framework.apollo.common.dto.ItemDTO;
28+
import com.ctrip.framework.apollo.common.exception.ServiceException;
29+
import com.ctrip.framework.apollo.portal.entity.bo.ItemBO;
30+
import com.ctrip.framework.apollo.portal.entity.bo.NamespaceBO;
31+
import com.ctrip.framework.apollo.portal.environment.Env;
32+
import com.ctrip.framework.apollo.portal.service.ConfigsExportService;
33+
import com.ctrip.framework.apollo.portal.service.NamespaceService;
34+
import java.io.IOException;
35+
import java.io.OutputStream;
36+
import java.util.Arrays;
37+
import java.util.Collections;
38+
import javax.servlet.ServletOutputStream;
39+
import javax.servlet.WriteListener;
40+
import javax.servlet.http.HttpServletRequest;
41+
import javax.servlet.http.HttpServletResponse;
42+
import org.junit.Test;
43+
import org.junit.runner.RunWith;
44+
import org.mockito.InjectMocks;
45+
import org.mockito.Mock;
46+
import org.mockito.junit.MockitoJUnitRunner;
47+
import org.springframework.http.HttpHeaders;
48+
import org.springframework.mock.web.MockHttpServletRequest;
49+
import org.springframework.mock.web.MockHttpServletResponse;
50+
51+
@RunWith(MockitoJUnitRunner.class)
52+
public class ConfigsExportControllerTest {
53+
54+
@Mock
55+
private ConfigsExportService configsExportService;
56+
57+
@Mock
58+
private NamespaceService namespaceService;
59+
60+
@InjectMocks
61+
private ConfigsExportController configsExportController;
62+
63+
@Test
64+
public void shouldExportNamespaceWithPropertiesSuffixWhenMissing() {
65+
NamespaceBO namespace = new NamespaceBO();
66+
namespace.setFormat("properties");
67+
namespace.setItems(Collections.singletonList(itemBO("timeout", "100")));
68+
69+
when(namespaceService.loadNamespaceBO("SampleApp", Env.DEV, "default", "application", true,
70+
false)).thenReturn(namespace);
71+
72+
MockHttpServletResponse response = new MockHttpServletResponse();
73+
74+
configsExportController.exportItems("SampleApp", "DEV", "default", "application", response);
75+
76+
assertEquals("attachment;filename=application.properties",
77+
response.getHeader(HttpHeaders.CONTENT_DISPOSITION));
78+
assertTrue(new String(response.getContentAsByteArray()).contains("\"key\":\"timeout\""));
79+
}
80+
81+
@Test
82+
public void shouldExportNamespaceKeepOriginalSuffixWhenFormatIsValid() {
83+
NamespaceBO namespace = new NamespaceBO();
84+
namespace.setFormat("yml");
85+
namespace.setItems(Collections.singletonList(itemBO("content", "a: b")));
86+
87+
when(namespaceService.loadNamespaceBO("SampleApp", Env.DEV, "default", "application.yml", true,
88+
false)).thenReturn(namespace);
89+
90+
MockHttpServletResponse response = new MockHttpServletResponse();
91+
92+
configsExportController.exportItems("SampleApp", "DEV", "default", "application.yml", response);
93+
94+
assertEquals("attachment;filename=application.yml",
95+
response.getHeader(HttpHeaders.CONTENT_DISPOSITION));
96+
assertTrue(new String(response.getContentAsByteArray()).contains("\"key\":\"content\""));
97+
}
98+
99+
@Test(expected = ServiceException.class)
100+
public void shouldWrapExportNamespaceIOExceptionAsServiceException() throws IOException {
101+
NamespaceBO namespace = new NamespaceBO();
102+
namespace.setFormat("properties");
103+
namespace.setItems(Collections.singletonList(itemBO("timeout", "100")));
104+
105+
when(namespaceService.loadNamespaceBO("SampleApp", Env.DEV, "default", "application", true,
106+
false)).thenReturn(namespace);
107+
108+
HttpServletResponse response = org.mockito.Mockito.mock(HttpServletResponse.class);
109+
when(response.getOutputStream()).thenReturn(new FailingServletOutputStream());
110+
111+
configsExportController.exportItems("SampleApp", "DEV", "default", "application", response);
112+
}
113+
114+
@Test
115+
public void shouldExportAllConfigsWithParsedEnvs() throws IOException {
116+
MockHttpServletRequest request = new MockHttpServletRequest();
117+
request.setRemoteAddr("127.0.0.1");
118+
request.setRemoteHost("localhost");
119+
MockHttpServletResponse response = new MockHttpServletResponse();
120+
121+
doAnswer(invocation -> {
122+
OutputStream outputStream = invocation.getArgument(0);
123+
outputStream.write("ok".getBytes());
124+
return null;
125+
}).when(configsExportService).exportData(any(OutputStream.class),
126+
eq(Arrays.asList(Env.DEV, Env.FAT)));
127+
128+
configsExportController.exportAll("DEV,FAT", request, response);
129+
130+
verify(configsExportService).exportData(any(OutputStream.class),
131+
eq(Arrays.asList(Env.DEV, Env.FAT)));
132+
assertTrue(response.getHeader(HttpHeaders.CONTENT_DISPOSITION)
133+
.startsWith("attachment;filename=apollo_config_export_"));
134+
assertEquals("ok", response.getContentAsString());
135+
}
136+
137+
@Test
138+
public void shouldExportAppConfigByEnvAndCluster() throws IOException {
139+
HttpServletRequest request = new MockHttpServletRequest();
140+
MockHttpServletResponse response = new MockHttpServletResponse();
141+
142+
doAnswer(invocation -> {
143+
OutputStream outputStream = invocation.getArgument(3);
144+
outputStream.write("app".getBytes());
145+
return null;
146+
}).when(configsExportService).exportAppConfigByEnvAndCluster(eq("SampleApp"), eq(Env.DEV),
147+
eq("default"), any(OutputStream.class));
148+
149+
configsExportController.exportAppConfig("SampleApp", "DEV", "default", request, response);
150+
151+
verify(configsExportService).exportAppConfigByEnvAndCluster(eq("SampleApp"), eq(Env.DEV),
152+
eq("default"), any(OutputStream.class));
153+
assertTrue(response.getHeader(HttpHeaders.CONTENT_DISPOSITION)
154+
.startsWith("attachment;filename=SampleApp+DEV+default+"));
155+
assertEquals("app", response.getContentAsString());
156+
}
157+
158+
private ItemBO itemBO(String key, String value) {
159+
ItemDTO itemDTO = new ItemDTO();
160+
itemDTO.setKey(key);
161+
itemDTO.setValue(value);
162+
ItemBO itemBO = new ItemBO();
163+
itemBO.setItem(itemDTO);
164+
return itemBO;
165+
}
166+
167+
private static class FailingServletOutputStream extends ServletOutputStream {
168+
169+
@Override
170+
public void write(int b) throws IOException {
171+
throw new IOException("forced write failure");
172+
}
173+
174+
@Override
175+
public boolean isReady() {
176+
return true;
177+
}
178+
179+
@Override
180+
public void setWriteListener(WriteListener writeListener) {
181+
// no-op
182+
}
183+
}
184+
}

0 commit comments

Comments
 (0)