Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 22 additions & 0 deletions .github/workflows/e2e-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Docker E2E

on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:

jobs:
cluster:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/AgileConfig.Server.UI/react-ui-antd
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
cache-dependency-path: src/AgileConfig.Server.UI/react-ui-antd/package-lock.json
- run: npm ci
- run: npm run test:e2e:docker
8 changes: 7 additions & 1 deletion .github/workflows/master-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ jobs:
working-directory: src/AgileConfig.Server.UI/react-ui-antd
strategy:
matrix:
node-version: [16.x]
node-version: [20.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- run: npm install
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: E2E_SKIP_UI_BUILD=true npm run test:e2e
build-dotnet:
runs-on: ubuntu-latest

Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/master-pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ jobs:
working-directory: src/AgileConfig.Server.UI/react-ui-antd
strategy:
matrix:
node-version: [16.x]
node-version: [20.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- run: npm install
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: E2E_SKIP_UI_BUILD=true npm run test:e2e
build-dotnet:
runs-on: ubuntu-latest

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,6 @@ __pycache__/
/src/AgileConfig.Server.Apisite/wwwroot/ui/
/src/AgileConfig.Server.Apisite/agile_config.db
/coverage
/src/AgileConfig.Server.UI/react-ui-antd/playwright-report/
/src/AgileConfig.Server.UI/react-ui-antd/test-results/
/AGENTS.md
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ WORKDIR /app
EXPOSE 5000

# 前端与架构无关,固定跑在构建机原生平台上,避免 QEMU 模拟拖慢 npm
FROM --platform=$BUILDPLATFORM node:16 AS ui-build
FROM --platform=$BUILDPLATFORM node:20 AS ui-build
WORKDIR /src/AgileConfig.Server.UI/react-ui-antd
COPY ["src/AgileConfig.Server.UI/react-ui-antd/package.json", "src/AgileConfig.Server.UI/react-ui-antd/package-lock.json", "./"]
RUN npm ci
Expand Down
20 changes: 20 additions & 0 deletions src/AgileConfig.Server.UI/react-ui-antd/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# End-to-end tests

The Playwright suite starts a production-mode Apisite on `127.0.0.1:5187` with a temporary SQLite database. It builds the React UI when `dist` is absent, copies that build into the API release output, then visits the UI through `/ui`.

Run the complete suite from `src/AgileConfig.Server.UI/react-ui-antd`:

```powershell
npx playwright install chromium
npm run test:e2e
```

Run an individual layer with `npm run test:e2e:ui` or `npm run test:e2e:protocol`. Failed runs retain traces, screenshots, and video in `test-results`.

The Docker cluster test is intentionally separate from the browser suite. It builds the image, starts MySQL plus three AgileConfig nodes, then publishes through the admin node and verifies a WebSocket reload and configuration retrieval from a second node:

```powershell
npm run test:e2e:docker
```

It requires a running Docker daemon. GitHub Actions runs it nightly at 02:00 China Standard Time and on manual dispatch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
services:
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: agileconfig
MYSQL_ROOT_PASSWORD: e2e-mysql-password
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -pe2e-mysql-password"]
interval: 5s
timeout: 5s
retries: 30

admin:
build:
context: ../../../../..
image: agileconfig-e2e:local
ports:
- "15100:5000"
environment: &server-environment
adminConsole: "true"
saPassword: e2e-admin-password
cluster: "true"
db__provider: mysql
db__conn: "database=agileconfig;data source=mysql;User Id=root;password=e2e-mysql-password;port=3306"
otlp__logs__endpoint: ""
otlp__traces__endpoint: ""
otlp__metrics__endpoint: ""
depends_on:
mysql:
condition: service_healthy

node1:
image: agileconfig-e2e:local
ports:
- "15101:5000"
environment:
<<: *server-environment
adminConsole: "false"
depends_on:
mysql:
condition: service_healthy
admin:
condition: service_started

node2:
image: agileconfig-e2e:local
ports:
- "15102:5000"
environment:
<<: *server-environment
adminConsole: "false"
depends_on:
mysql:
condition: service_healthy
admin:
condition: service_started
99 changes: 99 additions & 0 deletions src/AgileConfig.Server.UI/react-ui-antd/e2e/docker/run-cluster.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const { execFileSync } = require('child_process');
const { setTimeout: delay } = require('timers/promises');
const path = require('path');
const WebSocket = require('ws');

const composeFile = path.join(__dirname, 'docker-compose.yml');
const compose = (args) => execFileSync('docker', ['compose', '-f', composeFile, ...args], { stdio: 'inherit' });
const adminUrl = 'http://127.0.0.1:15100';
const nodeUrl = 'http://127.0.0.1:15102';

async function waitFor(url, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// The container has not completed startup yet.
}
await delay(1_000);
}
throw new Error(`Timed out waiting for ${url}.`);
}

async function json(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${options?.method ?? 'GET'} ${url} failed with ${response.status}.`);
return response.json();
}

async function main() {
compose(['up', '--build', '--detach']);
try {
await waitFor(`${adminUrl}/home/echo`);
await waitFor(`${nodeUrl}/home/echo`);

const login = await json(`${adminUrl}/admin/jwt/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userName: 'admin', password: 'e2e-admin-password' }),
});
if (login.status !== 'ok') throw new Error('Admin login failed.');
const adminHeaders = { Authorization: `Bearer ${login.token}`, 'Content-Type': 'application/json' };
const suffix = Date.now().toString();
const appId = `e2e-cluster-${suffix}`;
const secret = 'e2e-cluster-secret';

const app = await json(`${adminUrl}/app/add`, {
method: 'POST', headers: adminHeaders,
body: JSON.stringify({ id: appId, name: appId, secret, enabled: true, inheritanced: true }),
});
if (!app.success) throw new Error(`Application creation failed: ${app.message ?? 'unknown error'}.`);

const socket = new WebSocket(`${nodeUrl.replace('http', 'ws')}/ws?client_name=e2e-cluster`, {
headers: {
Authorization: `Basic ${Buffer.from(`${appId}:${secret}`).toString('base64')}`,
appid: appId,
env: 'DEV',
'client-v': '1.8.0',
},
});
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });

try {
const config = await json(`${adminUrl}/config/add?env=DEV`, {
method: 'POST', headers: adminHeaders,
body: JSON.stringify({ appId, key: 'cluster.reload', value: 'ok', group: '', description: 'E2E cluster' }),
});
if (!config.success) throw new Error(`Configuration creation failed: ${config.message ?? 'unknown error'}.`);

const reload = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for cross-node reload.')), 30_000);
socket.once('message', (data) => { clearTimeout(timeout); resolve(JSON.parse(data.toString())); });
});
const publish = await json(`${adminUrl}/config/publish?env=DEV`, {
method: 'POST', headers: adminHeaders,
body: JSON.stringify({ appId, ids: [], log: 'E2E cluster publish' }),
});
if (!publish.success) throw new Error(`Configuration publish failed: ${publish.message ?? 'unknown error'}.`);
const notification = await reload;
if (notification.Module !== 'c' || notification.Action !== 'reload') {
throw new Error(`Unexpected WebSocket notification: ${JSON.stringify(notification)}.`);
}

const published = await json(`${nodeUrl}/api/v2/applications/${appId}/environments/DEV/published-configurations`, {
headers: { Authorization: `Basic ${Buffer.from(`${appId}:${secret}`).toString('base64')}` },
});
if (!published.some((item) => item.key === 'cluster.reload' && item.value === 'ok')) {
throw new Error('Published configuration was not available from node2.');
}
} finally {
socket.close();
}
} finally {
compose(['down', '--volumes', '--remove-orphans']);
}
}

main().catch((error) => { console.error(error); process.exitCode = 1; });
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const { execFileSync, spawn } = require('child_process');
const { cpSync, existsSync, mkdtempSync, mkdirSync } = require('fs');
const { tmpdir } = require('os');
const path = require('path');

const frontendRoot = path.resolve(__dirname, '..', '..');
const repositoryRoot = path.resolve(frontendRoot, '..', '..', '..');
const apiProject = path.join(repositoryRoot, 'src', 'AgileConfig.Server.Apisite');
const apiOutput = path.join(apiProject, 'bin', 'Release', 'net10.0');
const uiSource = path.join(frontendRoot, 'dist');
const uiDestination = path.join(apiOutput, 'wwwroot', 'ui');
const runtimeDirectory = mkdtempSync(path.join(tmpdir(), 'agileconfig-e2e-'));
const databasePath = path.join(runtimeDirectory, 'agileconfig.db');

function run(command, args, cwd) {
execFileSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
}

if (!process.env.E2E_SKIP_UI_BUILD || !existsSync(uiSource)) {
run('npm', ['run', 'build'], frontendRoot);
}
run('dotnet', ['build', apiProject, '-c', 'Release', '--nologo'], repositoryRoot);

if (!existsSync(uiSource)) {
throw new Error(`React build output was not found at ${uiSource}.`);
}

mkdirSync(path.dirname(uiDestination), { recursive: true });
cpSync(uiSource, uiDestination, { recursive: true, force: true });

const environment = {
...process.env,
ASPNETCORE_ENVIRONMENT: 'Production',
urls: 'http://127.0.0.1:5187',
adminConsole: 'true',
saPassword: 'e2e-admin-password',
defaultApp: '',
cluster: 'false',
preview_mode: 'false',
alwaysTrustSsl: 'false',
serviceHealthCheckInterval: '60',
serviceUnhealthInterval: '60',
removeServiceInterval: '0',
db__provider: 'sqlite',
db__conn: `Data Source=${databasePath}`,
otlp__logs__endpoint: '',
otlp__traces__endpoint: '',
otlp__metrics__endpoint: '',
SSO__enabled: 'false',
};

const server = spawn('dotnet', ['run', '--no-build', '-c', 'Release', '--project', apiProject], {
cwd: repositoryRoot,
env: environment,
stdio: 'inherit',
});

function stopServer() {
if (!server.killed) server.kill();
}

process.once('SIGINT', stopServer);
process.once('SIGTERM', stopServer);
server.once('exit', (code) => process.exit(code ?? 0));
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test } from '@playwright/test';

test('an administrator can publish a configuration from the hosted management UI', async ({ page, request }) => {
const suffix = `${Date.now()}-${test.info().parallelIndex}`;
const appId = `e2e-ui-${suffix}`;
const appName = `E2E UI ${suffix}`;
const key = `e2e.key.${suffix}`;
const value = `value-${suffix}`;

const login = await request.post('/admin/jwt/login', {
data: { userName: 'admin', password: 'e2e-admin-password' },
});
await expect(login).toBeOK();
const { token } = await login.json();
const addNode = await request.post('/ServerNode/Add', {
headers: { Authorization: `Bearer ${token}` },
data: { address: 'http://127.0.0.1:5187', remark: 'E2E local node' },
});
await expect(addNode).toBeOK();

await page.goto('/ui#/user/login');
await page.getByRole('textbox', { name: 'Username', exact: true }).fill('admin');
await page.getByRole('textbox', { name: 'Password', exact: true }).fill('e2e-admin-password');
await page.getByRole('button', { name: 'Login', exact: true }).click();
await expect(page).toHaveURL(/#\/home$/);

await page.goto('/ui#/app');
await page.getByTestId('app-create').click();
const applicationDialog = page.getByRole('dialog');
await applicationDialog.locator('#app-create-name').fill(appName);
await applicationDialog.locator('#app-create-id').fill(appId);
await applicationDialog.locator('#app-create-secret').fill('e2e-app-secret');
await applicationDialog.getByTestId('app-create-submit').click();
await expect(page.getByText(appId, { exact: true })).toBeVisible();

await page.goto(`/ui#/app/config/${appId}/${encodeURIComponent(appName)}`);
await page.getByTestId('config-create').click();
const configurationDialog = page.getByRole('dialog');
await configurationDialog.locator('#config-create-key').fill(key);
await configurationDialog.locator('#config-create-value').fill(value);
await configurationDialog.getByTestId('config-create-submit').click();
await expect(page.getByText(key, { exact: true })).toBeVisible();

await page.getByTestId('config-publish-all').click();
const publishDialog = page.getByRole('dialog');
await publishDialog.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByText('Publish Success!', { exact: true })).toBeVisible();

const published = await request.get(
`/api/v2/applications/${appId}/environments/DEV/published-configurations`,
{
headers: {
Authorization: `Basic ${Buffer.from(`${appId}:e2e-app-secret`).toString('base64')}`,
},
},
);
await expect(published).toBeOK();
await expect(published.json()).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ key, value })]),
);
});
Loading
Loading