mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
@@ -91,6 +91,74 @@ describe('PagesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the complete GitHub latest discriminator payload', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({
|
||||
source: { source_type: 'manual' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
{
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({
|
||||
source: { source_type: 'manual' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
asset_name: 'site.tar.gz',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
{
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
asset_name: 'site.tar.gz',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads only the package multipart field', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||
const file = new File(['site'], 'site.zip', {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
|
||||
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
||||
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import {
|
||||
AdminTaskService,
|
||||
type TaskExecution,
|
||||
type TaskExecutionStatus,
|
||||
} from '@/lib/services/admin';
|
||||
import {
|
||||
type PagesDeployment,
|
||||
type PagesGitHubReleaseSource,
|
||||
type PagesRemoteURLSource,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
@@ -21,6 +27,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
getSource: vi.fn(),
|
||||
updateSource: vi.fn(),
|
||||
deleteSource: vi.fn(),
|
||||
checkSource: vi.fn(),
|
||||
syncSource: vi.fn(),
|
||||
listDeployments: vi.fn(),
|
||||
listDeploymentFiles: vi.fn(),
|
||||
@@ -67,11 +74,73 @@ const remoteSource: PagesRemoteURLSource = {
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
const githubLatestSource: PagesGitHubReleaseSource = {
|
||||
source_type: 'github_release',
|
||||
github_repository: 'openflare/site',
|
||||
release_selector: 'latest',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
sync_status: 'update_available',
|
||||
update_available: true,
|
||||
last_seen: {
|
||||
revision: 'b'.repeat(64),
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_applied: {
|
||||
revision: 'a'.repeat(64),
|
||||
label: 'v1.2.2',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_checked_at: '2026-07-19T10:00:00Z',
|
||||
last_synced_at: '2026-07-18T10:00:00Z',
|
||||
next_check_at: '2026-07-19T11:00:00Z',
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
const githubAttentionRevision = 'c'.repeat(64);
|
||||
const githubAttentionSource: PagesGitHubReleaseSource = {
|
||||
...githubLatestSource,
|
||||
sync_status: 'attention',
|
||||
update_available: true,
|
||||
last_seen: {
|
||||
revision: githubAttentionRevision,
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
};
|
||||
|
||||
function taskExecution(
|
||||
status: TaskExecutionStatus,
|
||||
errorMessage = '',
|
||||
): TaskExecution {
|
||||
return {
|
||||
id: '42',
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
task_type: 'of_pages_source_action',
|
||||
task_name: 'Pages 来源动作',
|
||||
status,
|
||||
retryable: false,
|
||||
max_retry: 0,
|
||||
retry_count: 0,
|
||||
log: '',
|
||||
error_message: errorMessage,
|
||||
result: '',
|
||||
duration: 1,
|
||||
payload: '',
|
||||
triggered_by: 'admin:1',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
updated_at: '2026-07-19T10:00:01Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pages source UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(PagesService.getSource).mockReset();
|
||||
vi.mocked(PagesService.updateSource).mockReset();
|
||||
vi.mocked(PagesService.deleteSource).mockReset();
|
||||
vi.mocked(PagesService.checkSource).mockReset();
|
||||
vi.mocked(PagesService.syncSource).mockReset();
|
||||
vi.mocked(PagesService.listDeployments).mockReset();
|
||||
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
||||
@@ -81,7 +150,8 @@ describe('Pages source UI', () => {
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
||||
});
|
||||
|
||||
it('keeps Phase 1 manual source focused on upload and Remote URL', async () => {
|
||||
it('offers the three Phase 2 source types without future repository build controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
@@ -92,8 +162,327 @@ describe('Pages source UI', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 Remote URL' }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
|
||||
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
|
||||
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the GitHub latest payload while keeping automatic controls hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: githubLatestSource,
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
|
||||
'dist.zip',
|
||||
);
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-canonical GitHub repository URL paths', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
|
||||
await user.type(repositoryInput, 'https://github.com//openflare/site');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
expect(
|
||||
screen.getByText(
|
||||
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await user.clear(repositoryInput);
|
||||
await user.type(repositoryInput, 'https://github.com/openflare/site/');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: {
|
||||
...githubLatestSource,
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
check_interval_minutes: undefined,
|
||||
next_check_at: null,
|
||||
},
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release tag'),
|
||||
'release/candidate#1&channel=stable',
|
||||
);
|
||||
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release Asset 文件名'),
|
||||
' site?arch=amd64#stable.zip ',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'release/candidate#1&channel=stable',
|
||||
asset_name: ' site?arch=amd64#stable.zip ',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe GitHub tag and asset values before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
|
||||
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release Asset 文件名'),
|
||||
'../dist.zip',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls the initial check receipt and represents the queued window locally', async () => {
|
||||
const user = userEvent.setup();
|
||||
const idleGitHubSource: PagesGitHubReleaseSource = {
|
||||
...githubLatestSource,
|
||||
sync_status: 'idle',
|
||||
update_available: false,
|
||||
};
|
||||
vi.mocked(PagesService.getSource)
|
||||
.mockResolvedValueOnce({ source_type: 'manual' })
|
||||
.mockResolvedValue(idleGitHubSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: idleGitHubSource,
|
||||
check_task: {
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
},
|
||||
warning: '',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('pending'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(screen.getByText('检查中')).toBeVisible();
|
||||
});
|
||||
const checkButton = screen.getByRole('button', { name: /检查更新/ });
|
||||
expect(checkButton).toBeDisabled();
|
||||
expect(within(checkButton).getByRole('status')).toBeVisible();
|
||||
expect(PagesService.checkSource).not.toHaveBeenCalled();
|
||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows GitHub release state and hides Phase 3 controls', async () => {
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('openflare/site')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
||||
expect(screen.getByText('有可用更新')).toBeVisible();
|
||||
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('succeeded'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a GitHub check dispatch error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockRejectedValue(
|
||||
new Error('GitHub API 暂不可用'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a TaskExecution polling error with an explicit retry', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
|
||||
new Error('任务状态暂时不可读取'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('requires the exact currently displayed revision for attention sync', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
|
||||
vi.mocked(PagesService.syncSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'sync',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('succeeded'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(
|
||||
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
|
||||
).toBeVisible();
|
||||
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '同步并发布' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: '确认并发布' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
|
||||
confirmed_revision: githubAttentionRevision,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('never reuses the masked URL as an editable value', async () => {
|
||||
@@ -119,7 +508,13 @@ describe('Pages source UI', () => {
|
||||
'https://artifacts.example.com/site.zip?token=...',
|
||||
);
|
||||
expect(input).toHaveValue('');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
await user.type(input, 'https://new.example.com/site.zip?token=new');
|
||||
expect(
|
||||
screen.queryByText('https://new.example.com/site.zip?token=new'),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '显示 Remote URL' }));
|
||||
expect(input).toHaveAttribute('type', 'url');
|
||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -130,6 +525,17 @@ describe('Pages source UI', () => {
|
||||
remote_network_policy: 'public',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('button', { name: '更换地址' }));
|
||||
const reopenedInput = screen.getByPlaceholderText(
|
||||
'https://artifacts.example.com/site.zip?token=...',
|
||||
);
|
||||
expect(reopenedInput).toHaveValue('');
|
||||
expect(reopenedInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('requires a second confirmation for trusted internal networking', async () => {
|
||||
@@ -169,24 +575,9 @@ describe('Pages source UI', () => {
|
||||
execution_id: '42',
|
||||
action: 'sync',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue({
|
||||
id: '42',
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
task_type: 'of_pages_source_action',
|
||||
task_name: 'Pages 来源动作',
|
||||
status: 'succeeded',
|
||||
retryable: false,
|
||||
max_retry: 0,
|
||||
retry_count: 0,
|
||||
log: '',
|
||||
error_message: '',
|
||||
result: '',
|
||||
duration: 1,
|
||||
payload: '',
|
||||
triggered_by: 'admin:1',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
updated_at: '2026-07-19T10:00:01Z',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution)
|
||||
.mockResolvedValueOnce(taskExecution('pending'))
|
||||
.mockResolvedValue(taskExecution('succeeded'));
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
@@ -196,6 +587,14 @@ describe('Pages source UI', () => {
|
||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: 3_500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the actual project entry and no one-off URL upload tab', () => {
|
||||
@@ -224,4 +623,30 @@ describe('Pages source UI', () => {
|
||||
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
|
||||
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
|
||||
const deployment: PagesDeployment = {
|
||||
id: 51,
|
||||
project_id: 9,
|
||||
deployment_number: 7,
|
||||
checksum: 'd'.repeat(64),
|
||||
status: 'active',
|
||||
file_count: 12,
|
||||
total_size: 4_096,
|
||||
created_by: 'user:1',
|
||||
source_type: 'github_release',
|
||||
source_label: 'v1.2.3',
|
||||
trigger_type: 'manual_sync',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
activated_at: '2026-07-19T10:00:01Z',
|
||||
};
|
||||
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
|
||||
|
||||
renderWithQuery(
|
||||
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
|
||||
expect(screen.getByText('当前生产部署')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
validGitHubAssetName,
|
||||
validGitHubReleaseTag,
|
||||
validGitHubRepositoryURL,
|
||||
} from '@/app/(main)/pages/detail/components/pages-source-validation';
|
||||
|
||||
describe('Pages GitHub source validation', () => {
|
||||
it('accepts only canonical public GitHub repository URLs', () => {
|
||||
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
|
||||
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const invalid = [
|
||||
'http://github.com/acme/site',
|
||||
'https://github.com//acme/site',
|
||||
'https://github.com/acme/site/',
|
||||
'https://github.com/acme/site/extra',
|
||||
'https://github.com/acme/./site',
|
||||
'https://github.com/acme/../site',
|
||||
'https://github.com/acme/%73ite',
|
||||
'https://github.com/acme/site?token=secret',
|
||||
'https://github.com:443/acme/site',
|
||||
String.raw`https://github.com/acme\site`,
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubRepositoryURL(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('mirrors Git ref rules while preserving legal release tag characters', () => {
|
||||
const valid = [
|
||||
'@',
|
||||
'release/v1#stable&channel=prod',
|
||||
'foo.LOCK',
|
||||
'中文/发布=稳定',
|
||||
];
|
||||
for (const value of valid) {
|
||||
expect(validGitHubReleaseTag(value), value).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = [
|
||||
'',
|
||||
'release v1',
|
||||
'release~v1',
|
||||
'release^v1',
|
||||
'release:v1',
|
||||
'release?v1',
|
||||
'release*v1',
|
||||
'release[v1',
|
||||
String.raw`release\v1`,
|
||||
'release..v1',
|
||||
'release@{v1',
|
||||
'release//v1',
|
||||
'/release',
|
||||
'release/',
|
||||
'release.',
|
||||
'.release',
|
||||
'release/.candidate',
|
||||
'release/v1.lock',
|
||||
'release\nsecret',
|
||||
'release\u2028secret',
|
||||
'release\u202esecret',
|
||||
'a'.repeat(256),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubReleaseTag(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves exact legal asset names and rejects path or display controls', () => {
|
||||
const valid = [
|
||||
'dist.zip',
|
||||
'dist?channel=stable&part#1.zip',
|
||||
' dist.zip ',
|
||||
'build=production.zip',
|
||||
];
|
||||
for (const value of valid) {
|
||||
expect(validGitHubAssetName(value), value).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = [
|
||||
'',
|
||||
'.',
|
||||
'..',
|
||||
'../dist.zip',
|
||||
String.raw`dir\dist.zip`,
|
||||
'dist\n.zip',
|
||||
'dist\u2028.zip',
|
||||
'dist\u202e.zip',
|
||||
'a'.repeat(256),
|
||||
'\ud800',
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubAssetName(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user