451 lines
20 KiB
TypeScript
451 lines
20 KiB
TypeScript
import type { Page, Route } from '@playwright/test';
|
|
import { expect, test } from '@playwright/test';
|
|
|
|
const permissions = [
|
|
'admin:user:view',
|
|
'admin:user:create',
|
|
'admin:update:view',
|
|
'admin:update:execute',
|
|
'audit:log:view',
|
|
];
|
|
|
|
function envelope(data: unknown) {
|
|
return { data, requestId: '01M00000000000000000000090' };
|
|
}
|
|
|
|
function session(canExecute = true) {
|
|
return {
|
|
authenticated: true,
|
|
user: {
|
|
publicId: '01M00000000000000000000001',
|
|
username: 'admin',
|
|
displayName: '系统管理员',
|
|
departmentName: '信息中心',
|
|
mustChangePassword: false,
|
|
},
|
|
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
|
|
activeRole: 'SYSTEM_ADMIN',
|
|
permissions: canExecute ? permissions : permissions.filter((permission) => permission !== 'admin:update:execute'),
|
|
};
|
|
}
|
|
|
|
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
|
|
const busy = [
|
|
'QUEUED',
|
|
'DOWNLOAD_QUEUED',
|
|
'INSTALL_QUEUED',
|
|
'PRECHECKING',
|
|
'VERIFYING',
|
|
'DOWNLOADING',
|
|
'BACKING_UP',
|
|
'INSTALLING',
|
|
'RUNNING',
|
|
].includes(state);
|
|
return {
|
|
enabled,
|
|
currentVersion: '1.0.0-preview.1',
|
|
latestVersion: checked ? '1.0.0-preview.2' : null,
|
|
updateAvailable: checked && !['CURRENT', 'SUCCEEDED'].includes(state),
|
|
state,
|
|
message: !enabled
|
|
? '在线更新未配置'
|
|
: recoveryPending
|
|
? '回滚未完成,更新服务将重试'
|
|
: state === 'DOWNLOAD_QUEUED'
|
|
? '下载请求已排队'
|
|
: state === 'INSTALL_QUEUED'
|
|
? '安装请求已排队'
|
|
: state === 'READY'
|
|
? '更新包已下载并通过签名校验'
|
|
: state === 'SUCCEEDED'
|
|
? '新版本已通过健康检查'
|
|
: checked
|
|
? '发现新版本'
|
|
: '尚未执行在线更新',
|
|
releaseNotes: checked ? 'Preview update' : null,
|
|
publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
|
|
checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
|
|
statusUpdatedAt: null,
|
|
downloadedBytes: state === 'READY' ? 10_485_760 : state === 'DOWNLOADING' ? 5_242_880 : 0,
|
|
totalBytes: ['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(state) ? 10_485_760 : null,
|
|
bytesPerSecond: state === 'DOWNLOADING' ? 1_048_576 : 0,
|
|
downloadPercent: state === 'READY' ? 100 : state === 'DOWNLOADING' ? 50 : 0,
|
|
restartExpectedSeconds: state === 'RUNNING' ? 10 : null,
|
|
events: checked
|
|
? [{ occurredAt: '2026-08-16T00:01:00Z', level: 'INFO', stage: state, message: `状态进入 ${state}` }]
|
|
: [],
|
|
allowedActions:
|
|
!enabled || recoveryPending || busy
|
|
? []
|
|
: state === 'READY'
|
|
? ['CHECK', 'INSTALL']
|
|
: checked
|
|
? ['CHECK', 'DOWNLOAD']
|
|
: ['CHECK'],
|
|
};
|
|
}
|
|
|
|
interface FixtureOptions {
|
|
canExecute?: boolean;
|
|
checkResponseState?: string;
|
|
downloadProgressReads?: number;
|
|
failDownloadResponse?: boolean;
|
|
failInstallResponse?: boolean;
|
|
restartOfflineReads?: number;
|
|
}
|
|
|
|
async function installFixture(
|
|
page: Page,
|
|
downloadBodies: unknown[],
|
|
installBodies: unknown[],
|
|
enabled = true,
|
|
recoveryPending = false,
|
|
completeAfterInstall = false,
|
|
initialState?: string,
|
|
completeBusyAfterFirstRead = false,
|
|
options: FixtureOptions = {},
|
|
) {
|
|
const {
|
|
canExecute = true,
|
|
checkResponseState,
|
|
downloadProgressReads = 0,
|
|
failDownloadResponse = false,
|
|
failInstallResponse = false,
|
|
restartOfflineReads = 0,
|
|
} = options;
|
|
let checked = recoveryPending || Boolean(initialState);
|
|
let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
|
|
let progressDeadline = 0;
|
|
let restartDeadline = 0;
|
|
let terminalHistoryRecorded = false;
|
|
const history: Array<Record<string, unknown>> = [];
|
|
const recordHistory = (
|
|
actionCode: string,
|
|
targetVersion = '1.0.0-preview.2',
|
|
reason = actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null,
|
|
) => {
|
|
history.unshift({
|
|
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
|
|
eventSequence: history.length + 1,
|
|
requestId: '01M00000000000000000000092',
|
|
username: 'admin',
|
|
activeRole: 'SYSTEM_ADMIN',
|
|
actionCode,
|
|
objectType: 'SYSTEM_UPDATE',
|
|
objectPublicId: 'SYSTEM_UPDATE',
|
|
resultCode: 'SUCCESS',
|
|
reason,
|
|
beforeJson: null,
|
|
afterJson: JSON.stringify({
|
|
targetVersion,
|
|
...(actionCode === 'SYSTEM_UPDATE_CHECK'
|
|
? { releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
|
|
: {}),
|
|
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED'
|
|
? { state: 'SUCCEEDED', releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
|
|
: {}),
|
|
}),
|
|
occurredAt: '2026-08-16T00:02:00Z',
|
|
allowedActions: [],
|
|
});
|
|
};
|
|
await page.route('**/api/v1/**', async (route: Route) => {
|
|
const request = route.request();
|
|
const pathname = new URL(request.url()).pathname;
|
|
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session(canExecute)) });
|
|
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session(canExecute).user) });
|
|
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session(canExecute).roles) });
|
|
if (pathname === '/api/v1/auth/csrf') {
|
|
return route.fulfill({
|
|
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
|
|
json: envelope({ token: 'update-csrf' }),
|
|
});
|
|
}
|
|
if (pathname === '/api/v1/admin/users') {
|
|
return route.fulfill({
|
|
json: {
|
|
data: [],
|
|
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
|
|
requestId: '01M00000000000000000000091',
|
|
},
|
|
});
|
|
}
|
|
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') {
|
|
if (state === 'RUNNING' && Date.now() < restartDeadline) {
|
|
return route.abort('connectionrefused');
|
|
}
|
|
if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED';
|
|
if (state === 'SUCCEEDED' && !terminalHistoryRecorded) {
|
|
recordHistory('SYSTEM_UPDATE_SUCCEEDED', '1.0.0-preview.2', '新版本已通过健康检查');
|
|
terminalHistoryRecorded = true;
|
|
}
|
|
const response = updateView(state, checked, enabled, recoveryPending);
|
|
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
|
|
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
|
|
return route.fulfill({ json: envelope(response) });
|
|
}
|
|
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
|
|
checked = true;
|
|
recordHistory('SYSTEM_UPDATE_CHECK');
|
|
if (checkResponseState) state = checkResponseState;
|
|
const response = updateView(state, checked, enabled, recoveryPending);
|
|
if (!canExecute) response.allowedActions = ['CHECK'];
|
|
return route.fulfill({ json: envelope(response) });
|
|
}
|
|
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
|
|
downloadBodies.push(request.postDataJSON());
|
|
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
|
|
state = downloadProgressReads > 0 ? 'DOWNLOADING' : 'READY';
|
|
progressDeadline = Date.now() + downloadProgressReads * 1000;
|
|
if (failDownloadResponse) return route.abort('connectionreset');
|
|
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
|
|
}
|
|
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
|
|
installBodies.push(request.postDataJSON());
|
|
recordHistory('SYSTEM_UPDATE_REQUEST');
|
|
const queued = updateView('INSTALL_QUEUED', true, enabled);
|
|
restartDeadline = Date.now() + restartOfflineReads * 1000;
|
|
state =
|
|
restartOfflineReads > 0
|
|
? 'RUNNING'
|
|
: failInstallResponse || completeAfterInstall
|
|
? 'SUCCEEDED'
|
|
: 'INSTALL_QUEUED';
|
|
if (failInstallResponse) return route.abort('connectionreset');
|
|
return route.fulfill({ json: envelope(queued) });
|
|
}
|
|
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
|
|
const query = new URL(request.url()).searchParams;
|
|
expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z');
|
|
expect(query.get('objectType')).toBe('SYSTEM_UPDATE');
|
|
expect(query.get('size')).toBe('100');
|
|
return route.fulfill({
|
|
json: {
|
|
data: history,
|
|
meta: { page: 1, size: 20, totalElements: history.length, totalPages: history.length ? 1 : 0 },
|
|
requestId: '01M00000000000000000000093',
|
|
},
|
|
});
|
|
}
|
|
if (request.method() === 'GET') return route.fulfill({ json: envelope([]) });
|
|
return route.fulfill({ status: 204 });
|
|
});
|
|
}
|
|
|
|
test('system administrator checks, downloads, and explicitly installs a signed online update', async ({ page }) => {
|
|
const downloadBodies: unknown[] = [];
|
|
const installBodies: unknown[] = [];
|
|
await installFixture(page, downloadBodies, installBodies);
|
|
await page.goto('/governance/update');
|
|
|
|
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
|
|
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
|
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
|
expect(downloadBodies).toEqual([]);
|
|
|
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
|
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
|
|
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
|
|
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
|
await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
|
|
|
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
|
|
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即更新并重启' }]);
|
|
});
|
|
|
|
test('download dialog shows real byte progress and transfer speed without a false permission warning', async ({
|
|
page,
|
|
}) => {
|
|
await installFixture(page, [], [], true, false, false, undefined, false, { downloadProgressReads: 8 });
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
|
|
|
const dialog = page.getByRole('dialog', { name: '系统更新' });
|
|
await expect(dialog.getByText('50%', { exact: true })).toBeVisible({ timeout: 5_000 });
|
|
await expect(dialog.getByText('1.0 MB/s', { exact: true })).toBeVisible();
|
|
await expect(dialog.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
|
await expect(dialog.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible({ timeout: 12_000 });
|
|
});
|
|
|
|
test('restart dialog keeps counting down and reconnects while the backend is temporarily offline', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, 'READY', false, { restartOfflineReads: 8 });
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
|
|
|
const dialog = page.getByRole('dialog', { name: '系统更新' });
|
|
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
await expect(dialog.getByText('应用服务连接中断,正在等待进程重启', { exact: true })).toBeVisible();
|
|
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
});
|
|
|
|
test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, 'READY');
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
|
|
await expect(page.getByText('更新包已下载完成,请点击“立即更新并重启”安装新版本', { exact: true })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('view-only administrators see a download permission message', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, undefined, false, { canExecute: false });
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
|
|
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('a check response that is already queued is treated as busy instead of missing permission', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, undefined, false, {
|
|
checkResponseState: 'DOWNLOAD_QUEUED',
|
|
});
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
|
|
await expect(page.getByRole('dialog', { name: '系统更新' })).toBeVisible();
|
|
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
|
await expect(page.getByText('下载请求已排队', { exact: true }).first()).toBeVisible();
|
|
});
|
|
|
|
test('download and install continue polling after an accepted command loses its response', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, undefined, false, {
|
|
failDownloadResponse: true,
|
|
});
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
|
await expect(
|
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('accepted install continues to the refresh countdown after its response is lost', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, undefined, false, {
|
|
failInstallResponse: true,
|
|
});
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
|
await expect(
|
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
|
|
|
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
|
timeout: 8_000,
|
|
});
|
|
});
|
|
|
|
test('a lost install response stays pending while the application restarts', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, 'READY', false, {
|
|
failInstallResponse: true,
|
|
restartOfflineReads: 4,
|
|
});
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
|
|
|
const dialog = page.getByRole('dialog', { name: '系统更新' });
|
|
await expect(dialog.getByText('立即更新并重启失败', { exact: false })).toHaveCount(0);
|
|
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
|
|
test('brand logo returns the active identity to its workbench', async ({ page }) => {
|
|
await installFixture(page, [], []);
|
|
await page.goto('/governance/update');
|
|
|
|
await page.locator('.brand-logo').click();
|
|
|
|
await expect(page).toHaveURL(/\/governance\/settings$/);
|
|
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('disabled online updates expose status without an executable check action', async ({ page }) => {
|
|
await installFixture(page, [], [], false);
|
|
await page.goto('/governance/update');
|
|
|
|
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: '获取版本', exact: true })).toBeDisabled();
|
|
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
|
|
await installFixture(page, [], [], true, true);
|
|
await page.goto('/governance/update');
|
|
|
|
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, true);
|
|
await page.goto('/governance/update');
|
|
|
|
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
|
await page.getByRole('button', { name: '立即更新', exact: true }).click({ timeout: 5_000 });
|
|
await expect(
|
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
|
|
|
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
const historyPanel = page.locator('.history-panel');
|
|
const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]');
|
|
await expect(timelineItem).toHaveCount(1);
|
|
await expect(timelineItem).toContainText('成功');
|
|
await expect(timelineItem).toContainText('新版本已通过健康检查');
|
|
await expect(timelineItem).toContainText('Preview update');
|
|
await timelineItem.getByRole('button', { name: '查看更新日志', exact: true }).click();
|
|
const historyDialog = page.getByRole('dialog', { name: '更新日志详情' });
|
|
await expect(historyDialog).toContainText('更新日志');
|
|
await expect(historyDialog).toContainText('Preview update');
|
|
await expect(historyDialog).toContainText('发布时间');
|
|
await expect(historyDialog.locator('.history-detail__steps')).toHaveCount(0);
|
|
await expect(historyDialog).not.toContainText('获取版本');
|
|
await expect(historyDialog).not.toContainText('下载更新包');
|
|
await expect(historyDialog).not.toContainText('立即更新并重启');
|
|
});
|
|
|
|
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
|
|
await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
|
|
await page.goto('/governance/update');
|
|
|
|
await expect(
|
|
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('安装中', { exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
|
timeout: 6_000,
|
|
});
|
|
});
|