diff --git a/frontend/app/(main)/pages/detail/components/deployment-history.tsx b/frontend/app/(main)/pages/detail/components/deployment-history.tsx index 7fe01105..76227e2d 100644 --- a/frontend/app/(main)/pages/detail/components/deployment-history.tsx +++ b/frontend/app/(main)/pages/detail/components/deployment-history.tsx @@ -119,7 +119,7 @@ export function DeploymentHistory({ mutationFn: (deploymentId: number) => PagesService.activateDeployment(projectId, deploymentId), onSuccess: async () => { - toast.success('部署已激活'); + toast.success('历史部署已激活;自动更新(如已开启)已关闭'); await invalidateDeploymentState(); setPendingAction(null); }, diff --git a/frontend/app/(main)/pages/detail/components/pages-source-card.tsx b/frontend/app/(main)/pages/detail/components/pages-source-card.tsx index 9e101436..c9a19872 100644 --- a/frontend/app/(main)/pages/detail/components/pages-source-card.tsx +++ b/frontend/app/(main)/pages/detail/components/pages-source-card.tsx @@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { AdminTaskService } from '@/lib/services/admin'; import { + type PagesSource, type PagesSourceActionPayload, type PagesSourceActionReceipt, type PagesSourceStatus, @@ -58,6 +59,9 @@ import { const ACTION_POLL_INTERVAL = 2_000; const ACTION_MAX_WAIT = 16 * 60 * 1_000; +const LATEST_POLL_INTERVAL = 5 * 60 * 1_000; +const LATEST_NEAR_DUE_POLL_INTERVAL = 30_000; +const LATEST_OVERDUE_MAX_WAIT = 10 * 60 * 1_000; const SOURCE_STATUS: Record< PagesSourceStatus, @@ -79,6 +83,60 @@ interface ActiveAction { startedAt: number; } +export interface LatestSourceOverdueWindow { + nextCheckAt: string; + startedAt: number; +} + +export interface LatestSourcePollingDecision { + interval: number | false; + overdueWindow: LatestSourceOverdueWindow | null; +} + +export function getLatestSourceIdlePollingDecision( + source: PagesSource | undefined, + now: number, + overdueWindow: LatestSourceOverdueWindow | null, +): LatestSourcePollingDecision { + if ( + source?.source_type !== 'github_release' || + source.release_selector !== 'latest' + ) { + return { interval: false, overdueWindow: null }; + } + + const nextCheckAt = source.next_check_at; + const nextCheckTime = nextCheckAt ? Date.parse(nextCheckAt) : Number.NaN; + if (!nextCheckAt || !Number.isFinite(nextCheckTime)) { + return { interval: LATEST_POLL_INTERVAL, overdueWindow: null }; + } + + const timeUntilCheck = nextCheckTime - now; + if (timeUntilCheck > LATEST_POLL_INTERVAL) { + return { interval: LATEST_POLL_INTERVAL, overdueWindow: null }; + } + if (timeUntilCheck > 0) { + return { interval: LATEST_NEAR_DUE_POLL_INTERVAL, overdueWindow: null }; + } + + const currentWindow = + overdueWindow?.nextCheckAt === nextCheckAt + ? overdueWindow + : { nextCheckAt, startedAt: now }; + if (now - currentWindow.startedAt >= LATEST_OVERDUE_MAX_WAIT) { + return { interval: false, overdueWindow: currentWindow }; + } + return { + interval: LATEST_NEAR_DUE_POLL_INTERVAL, + overdueWindow: currentWindow, + }; +} + +function sourceDeploymentFingerprint(source: PagesSource) { + if (source.source_type === 'manual') return '|'; + return `${source.last_synced_at ?? ''}|${source.last_applied?.revision ?? ''}`; +} + function sourceActionLabel(action: PagesSourceActionReceipt['action']) { return action === 'check' ? '检查' : '同步并发布'; } @@ -87,6 +145,8 @@ export function PagesSourceCard({ projectId }: { projectId: number }) { const queryClient = useQueryClient(); const handledExecutionID = useRef(null); const sourcePollingStartedAt = useRef(null); + const latestOverdueWindow = useRef(null); + const sourceDeploymentState = useRef(undefined); const [dialogOpen, setDialogOpen] = useState(false); const [dialogMode, setDialogMode] = useState('manual'); const [activeAction, setActiveAction] = useState(null); @@ -104,15 +164,40 @@ export function PagesSourceCard({ projectId }: { projectId: number }) { (source.sync_status === 'checking' || source.sync_status === 'syncing') ) { sourcePollingStartedAt.current ??= Date.now(); + latestOverdueWindow.current = null; return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT ? ACTION_POLL_INTERVAL : false; } sourcePollingStartedAt.current = null; - return false; + const decision = getLatestSourceIdlePollingDecision( + source, + Date.now(), + latestOverdueWindow.current, + ); + latestOverdueWindow.current = decision.overdueWindow; + return decision.interval; }, }); + const invalidateSourceState = useCallback( + () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }), + queryClient.invalidateQueries({ + queryKey: projectQueryKey(projectId), + }), + queryClient.invalidateQueries({ + queryKey: deploymentsQueryKey(projectId), + }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'pages', 'deployment-files', projectId], + }), + queryClient.invalidateQueries({ queryKey: projectsQueryKey }), + ]), + [projectId, queryClient], + ); + const executionQuery = useQuery({ queryKey: [ 'admin', @@ -151,6 +236,21 @@ export function PagesSourceCard({ projectId }: { projectId: number }) { return () => window.clearTimeout(timeout); }, [actionTimedOut, activeAction]); + useEffect(() => { + const source = sourceQuery.data; + if (!source) return; + const fingerprint = sourceDeploymentFingerprint(source); + const previousFingerprint = sourceDeploymentState.current; + sourceDeploymentState.current = fingerprint; + if ( + previousFingerprint === undefined || + previousFingerprint === fingerprint + ) { + return; + } + void invalidateSourceState(); + }, [invalidateSourceState, sourceQuery.data]); + useEffect(() => { const execution = executionQuery.data; if ( @@ -163,17 +263,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) { if (handledExecutionID.current === execution.id) return; handledExecutionID.current = execution.id; - void Promise.all([ - queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }), - queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }), - queryClient.invalidateQueries({ - queryKey: deploymentsQueryKey(projectId), - }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'pages', 'deployment-files', projectId], - }), - queryClient.invalidateQueries({ queryKey: projectsQueryKey }), - ]); + void invalidateSourceState(); const actionLabel = sourceActionLabel(activeAction.receipt.action); if (execution.status === 'succeeded') { @@ -183,7 +273,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) { } setActiveAction(null); setActionTimedOut(false); - }, [activeAction, executionQuery.data, projectId, queryClient]); + }, [activeAction, executionQuery.data, invalidateSourceState]); const checkMutation = useMutation({ mutationFn: () => PagesService.checkSource(projectId), diff --git a/frontend/app/(main)/pages/detail/components/pages-source-details.tsx b/frontend/app/(main)/pages/detail/components/pages-source-details.tsx index 100db35b..f7812b18 100644 --- a/frontend/app/(main)/pages/detail/components/pages-source-details.tsx +++ b/frontend/app/(main)/pages/detail/components/pages-source-details.tsx @@ -98,6 +98,32 @@ export function GitHubSourceDetails({ Release Asset {source.asset_name} + {source.release_selector === 'latest' ? ( + <> +
+ 自动更新 + + {source.auto_update_enabled ? '已开启' : '已关闭'} + +
+
+ 检查间隔 + + {source.check_interval_minutes} 分钟 + +
+
+ + 下次检查时间 + + + {source.next_check_at + ? formatDateTime(source.next_check_at) + : '等待调度'} + +
+ + ) : null}
远端已发现 diff --git a/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx b/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx index 3915bcdf..73968f92 100644 --- a/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx +++ b/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Eye, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; @@ -78,6 +78,7 @@ const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = { repository: '', releaseTag: '', assetName: '', + checkInterval: '', }; function githubRepositoryURL(repository: string) { @@ -106,17 +107,25 @@ export function PagesSourceDialog({ releaseSelector: 'latest', releaseTag: '', assetName: DEFAULT_GITHUB_ASSET, + autoUpdateEnabled: false, + checkIntervalMinutes: String(DEFAULT_GITHUB_CHECK_INTERVAL), }); const [githubErrors, setGitHubErrors] = useState(EMPTY_GITHUB_ERRORS); const [confirmation, setConfirmation] = useState(null); + const initializedForOpen = useRef(false); useEffect(() => { if (!open) { + initializedForOpen.current = false; setRemoteURL(''); setRevealRemoteURL(false); return; } + // Runtime polling may replace the source view while the dialog is open. + // Initialize only on the open edge so it cannot overwrite an unsaved draft. + if (initializedForOpen.current) return; + initializedForOpen.current = true; const nextMode = initialMode ?? source.source_type; setMode(nextMode); setNetworkPolicy( @@ -145,6 +154,18 @@ export function PagesSourceDialog({ source.source_type === 'github_release' ? source.asset_name : DEFAULT_GITHUB_ASSET, + autoUpdateEnabled: + source.source_type === 'github_release' && + source.release_selector === 'latest' + ? source.auto_update_enabled + : false, + checkIntervalMinutes: + source.source_type === 'github_release' && + source.release_selector === 'latest' + ? String( + source.check_interval_minutes || DEFAULT_GITHUB_CHECK_INTERVAL, + ) + : String(DEFAULT_GITHUB_CHECK_INTERVAL), }); setGitHubErrors(EMPTY_GITHUB_ERRORS); setConfirmation(null); @@ -243,13 +264,27 @@ export function PagesSourceDialog({ const nextAssetNameError = validGitHubAssetName(githubForm.assetName) ? '' : 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符'; + const checkIntervalMinutes = Number(githubForm.checkIntervalMinutes); + const nextCheckIntervalError = + githubForm.releaseSelector === 'latest' && + (!Number.isInteger(checkIntervalMinutes) || + checkIntervalMinutes < 5 || + checkIntervalMinutes > 1440) + ? '检查间隔须为 5–1440 分钟的整数' + : ''; setGitHubErrors({ repository: nextRepositoryError, releaseTag: nextReleaseTagError, assetName: nextAssetNameError, + checkInterval: nextCheckIntervalError, }); - if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) { + if ( + nextRepositoryError || + nextReleaseTagError || + nextAssetNameError || + nextCheckIntervalError + ) { return; } @@ -261,13 +296,8 @@ export function PagesSourceDialog({ release_selector: 'latest', release_tag: '', asset_name: githubForm.assetName, - auto_update_enabled: false, - check_interval_minutes: - source.source_type === 'github_release' && - source.release_selector === 'latest' && - source.check_interval_minutes - ? source.check_interval_minutes - : DEFAULT_GITHUB_CHECK_INTERVAL, + auto_update_enabled: githubForm.autoUpdateEnabled, + check_interval_minutes: checkIntervalMinutes, } : { source_type: 'github_release', diff --git a/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx b/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx index 7a8d3422..84ec99a4 100644 --- a/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx +++ b/frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx @@ -1,10 +1,13 @@ import { Field, + FieldContent, FieldDescription, + FieldError, FieldLabel, FieldTitle, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare'; @@ -13,12 +16,15 @@ export interface PagesGitHubSourceFormValue { releaseSelector: PagesGitHubReleaseSelector; releaseTag: string; assetName: string; + autoUpdateEnabled: boolean; + checkIntervalMinutes: string; } export interface PagesGitHubSourceFormErrors { repository: string; releaseTag: string; assetName: string; + checkInterval: string; } interface PagesSourceGitHubFieldsProps { @@ -48,15 +54,19 @@ export function PagesSourceGitHubFields({ placeholder='https://github.com/owner/repo' value={value.repositoryURL} aria-invalid={Boolean(errors.repository)} + aria-describedby='pages-github-repository-description pages-github-repository-error' autoComplete='off' onChange={(event) => { onChange({ ...value, repositoryURL: event.target.value }); onErrorsChange({ ...errors, repository: '' }); }} /> - - {errors.repository || '仅支持公开 github.com 仓库。'} + + 仅支持公开 github.com 仓库。 + + {errors.repository} + @@ -69,8 +79,17 @@ export function PagesSourceGitHubFields({ className='grid w-full grid-cols-2' onValueChange={(selector) => { if (selector === 'latest' || selector === 'tag') { - onChange({ ...value, releaseSelector: selector }); - onErrorsChange({ ...errors, releaseTag: '' }); + onChange({ + ...value, + releaseSelector: selector, + autoUpdateEnabled: + selector === 'latest' ? value.autoUpdateEnabled : false, + }); + onErrorsChange({ + ...errors, + releaseTag: '', + checkInterval: '', + }); } }} > @@ -82,7 +101,9 @@ export function PagesSourceGitHubFields({ - 当前阶段由管理员手动检查并决定是否发布。 + {value.releaseSelector === 'latest' + ? '定时检查最新 Release;是否自动发布由下方开关控制。' + : '固定 Tag 仅支持管理员手动检查与同步。'} @@ -94,18 +115,74 @@ export function PagesSourceGitHubFields({ placeholder='v1.2.3' value={value.releaseTag} aria-invalid={Boolean(errors.releaseTag)} + aria-describedby='pages-github-tag-description pages-github-tag-error' autoComplete='off' onChange={(event) => { onChange({ ...value, releaseTag: event.target.value }); onErrorsChange({ ...errors, releaseTag: '' }); }} /> - - {errors.releaseTag || '精确检查并同步指定 tag。'} + + 精确检查并同步指定 tag。 + + {errors.releaseTag} + ) : null} + {value.releaseSelector === 'latest' ? ( + <> + + + + 自动更新 + + + 检查到新的 Release 后自动同步并发布。 + + + + onChange({ ...value, autoUpdateEnabled: checked }) + } + /> + + + + + 检查间隔(分钟) + + { + onChange({ + ...value, + checkIntervalMinutes: event.target.value, + }); + onErrorsChange({ ...errors, checkInterval: '' }); + }} + /> + + 可设置为 5–1440 分钟。 + + + {errors.checkInterval} + + + + ) : null} + Release Asset 文件名 @@ -115,15 +192,19 @@ export function PagesSourceGitHubFields({ placeholder={defaultAssetName} value={value.assetName} aria-invalid={Boolean(errors.assetName)} + aria-describedby='pages-github-asset-description pages-github-asset-error' autoComplete='off' onChange={(event) => { onChange({ ...value, assetName: event.target.value }); onErrorsChange({ ...errors, assetName: '' }); }} /> - - {errors.assetName || '按文件名精确匹配已上传的 Release Asset。'} + + 按文件名精确匹配已上传的 Release Asset。 + + {errors.assetName} + ); diff --git a/frontend/app/(main)/pages/detail/page.tsx b/frontend/app/(main)/pages/detail/page.tsx index e653471b..bdeb0c6f 100644 --- a/frontend/app/(main)/pages/detail/page.tsx +++ b/frontend/app/(main)/pages/detail/page.tsx @@ -252,8 +252,9 @@ function PagesDetailRoute() {
- + diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 66eace1c..237bc85e 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -492,16 +492,29 @@ export interface PagesRemoteURLSource extends PagesSourceRuntimeView { remote_network_policy: PagesRemoteNetworkPolicy; } -export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView { +interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView { source_type: 'github_release'; github_repository: string; - release_selector: PagesGitHubReleaseSelector; - release_tag?: string; asset_name: string; - auto_update_enabled?: false; - check_interval_minutes?: number; } +interface PagesGitHubLatestReleaseSource extends PagesGitHubReleaseSourceBase { + release_selector: 'latest'; + release_tag?: ''; + auto_update_enabled: boolean; + check_interval_minutes: number; +} + +interface PagesGitHubTagReleaseSource extends PagesGitHubReleaseSourceBase { + release_selector: 'tag'; + release_tag: string; + auto_update_enabled: false; + check_interval_minutes?: 0; +} + +export type PagesGitHubReleaseSource = + PagesGitHubLatestReleaseSource | PagesGitHubTagReleaseSource; + /** * 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant, * 不需要向 Remote 或 GitHub Release 填入构建字段。 @@ -520,18 +533,19 @@ interface PagesGitHubSourceUpdateBase { source_type: 'github_release'; repository_url: string; asset_name: string; - auto_update_enabled: false; } export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase { release_selector: 'latest'; release_tag: ''; + auto_update_enabled: boolean; check_interval_minutes: number; } export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase { release_selector: 'tag'; release_tag: string; + auto_update_enabled: false; check_interval_minutes: 0; } diff --git a/frontend/tests/openflare/pages-source-auto-update.test.tsx b/frontend/tests/openflare/pages-source-auto-update.test.tsx new file mode 100644 index 00000000..6ae7db61 --- /dev/null +++ b/frontend/tests/openflare/pages-source-auto-update.test.tsx @@ -0,0 +1,355 @@ +import { act } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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 { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history'; +import { + getLatestSourceIdlePollingDecision, + PagesSourceCard, +} from '@/app/(main)/pages/detail/components/pages-source-card'; +import { + deploymentsQueryKey, + projectQueryKey, + projectsQueryKey, + sourceQueryKey, +} from '@/app/(main)/pages/components/pages-utils'; +import { + type PagesDeployment, + type PagesGitHubReleaseSource, + PagesService, +} from '@/lib/services/openflare'; + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + PagesService: { + getSource: vi.fn(), + updateSource: vi.fn(), + deleteSource: vi.fn(), + checkSource: vi.fn(), + syncSource: vi.fn(), + listDeployments: vi.fn(), + listDeploymentFiles: vi.fn(), + activateDeployment: vi.fn(), + deleteDeployment: vi.fn(), + }, + }; +}); + +function renderWithQuery(ui: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + const rendered = render( + {ui}, + ); + return { + ...rendered, + queryClient, + rerenderWithQuery: (nextUI: React.ReactNode) => + rendered.rerender( + + {nextUI} + , + ), + }; +} + +const latestSource: PagesGitHubReleaseSource = { + source_type: 'github_release', + github_repository: 'openflare/site', + release_selector: 'latest', + asset_name: 'dist.zip', + auto_update_enabled: true, + check_interval_minutes: 120, + sync_status: 'idle', + update_available: false, + 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-19T09:00:00Z', + next_check_at: '2026-07-19T12:00:00Z', + last_error: '', +}; + +describe('Pages latest source automatic updates', () => { + 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(); + vi.mocked(PagesService.activateDeployment).mockReset(); + vi.mocked(PagesService.deleteDeployment).mockReset(); + }); + + it('backfills latest settings and hides them after selecting a fixed tag', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '编辑来源' })); + expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked(); + expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120); + + await user.click(screen.getByRole('radio', { name: '固定 Tag' })); + expect( + screen.queryByRole('switch', { name: '自动更新' }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument(); + }); + + it('does not show automatic schedule details for a fixed tag source', async () => { + vi.mocked(PagesService.getSource).mockResolvedValue({ + ...latestSource, + release_selector: 'tag', + release_tag: 'v1.2.3', + auto_update_enabled: false, + check_interval_minutes: 0, + next_check_at: null, + }); + + renderWithQuery(); + + expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible(); + expect(screen.queryByText('自动更新')).not.toBeInTheDocument(); + expect(screen.queryByText('检查间隔')).not.toBeInTheDocument(); + expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument(); + }); + + it('rejects a latest interval outside 5–1440 minutes', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '编辑来源' })); + const intervalInput = screen.getByLabelText('检查间隔(分钟)'); + await user.clear(intervalInput); + await user.type(intervalInput, '4'); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + + expect(screen.getByRole('alert')).toHaveTextContent( + '检查间隔须为 5–1440 分钟的整数', + ); + expect(intervalInput).toHaveAttribute( + 'aria-describedby', + 'pages-github-check-interval-description pages-github-check-interval-error', + ); + expect(PagesService.updateSource).not.toHaveBeenCalled(); + }); + + it('keeps an unsaved source draft when idle polling updates runtime fields', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + const { queryClient } = renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '编辑来源' })); + const assetInput = screen.getByLabelText('Release Asset 文件名'); + const intervalInput = screen.getByLabelText('检查间隔(分钟)'); + await user.clear(assetInput); + await user.type(assetInput, 'draft.zip'); + await user.clear(intervalInput); + await user.type(intervalInput, '30'); + await user.click(screen.getByRole('switch', { name: '自动更新' })); + + act(() => { + queryClient.setQueryData(sourceQueryKey(9), { + ...latestSource, + last_checked_at: '2026-07-19T10:30:00Z', + next_check_at: '2026-07-19T12:30:00Z', + }); + }); + + await waitFor(() => { + expect(assetInput).toHaveValue('draft.zip'); + expect(intervalInput).toHaveValue(30); + expect( + screen.getByRole('switch', { name: '自动更新' }), + ).not.toBeChecked(); + }); + }); + + it('remounts project-scoped source state when the route project changes', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockImplementation(async (projectId) => ({ + ...latestSource, + github_repository: + projectId === 9 ? 'openflare/project-nine' : 'openflare/project-ten', + })); + const { rerenderWithQuery } = renderWithQuery( + , + ); + + await user.click(await screen.findByRole('button', { name: '编辑来源' })); + expect(screen.getByRole('dialog')).toBeVisible(); + + rerenderWithQuery(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(await screen.findByText('openflare/project-ten')).toBeVisible(); + }); + + it('uses low-frequency, near-due and bounded overdue polling', () => { + const now = Date.parse('2026-07-19T10:00:00Z'); + const farSource = { + ...latestSource, + next_check_at: new Date(now + 60 * 60 * 1_000).toISOString(), + }; + expect(getLatestSourceIdlePollingDecision(farSource, now, null)).toEqual({ + interval: 5 * 60 * 1_000, + overdueWindow: null, + }); + + const nearSource = { + ...latestSource, + next_check_at: new Date(now + 60_000).toISOString(), + }; + expect(getLatestSourceIdlePollingDecision(nearSource, now, null)).toEqual({ + interval: 30_000, + overdueWindow: null, + }); + + const overdueSource = { + ...latestSource, + next_check_at: new Date(now - 1).toISOString(), + }; + const overdue = getLatestSourceIdlePollingDecision( + overdueSource, + now, + null, + ); + expect(overdue.interval).toBe(30_000); + expect( + getLatestSourceIdlePollingDecision( + overdueSource, + now + 10 * 60 * 1_000, + overdue.overdueWindow, + ).interval, + ).toBe(false); + + expect( + getLatestSourceIdlePollingDecision( + { + ...latestSource, + release_selector: 'tag', + release_tag: 'v1.2.3', + auto_update_enabled: false, + check_interval_minutes: 0, + }, + now, + null, + ).interval, + ).toBe(false); + }); + + it('refreshes source consumers after a background deployment is applied', async () => { + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + const { queryClient } = renderWithQuery(); + expect(await screen.findByText('openflare/site')).toBeVisible(); + + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const updatedSource: PagesGitHubReleaseSource = { + ...latestSource, + last_applied: { + revision: 'b'.repeat(64), + label: 'v1.2.3', + asset_name: 'dist.zip', + }, + last_synced_at: '2026-07-19T10:30:00Z', + }; + vi.mocked(PagesService.getSource).mockResolvedValue(updatedSource); + + act(() => { + queryClient.setQueryData(sourceQueryKey(9), updatedSource); + }); + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: sourceQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: deploymentsQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['openflare', 'pages', 'deployment-files', 9], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectsQueryKey, + }); + }); + }); + + it('warns that rollback closes auto update and refreshes four query groups', async () => { + const user = userEvent.setup(); + const deployment: PagesDeployment = { + id: 51, + project_id: 9, + deployment_number: 7, + checksum: 'd'.repeat(64), + status: 'uploaded', + file_count: 12, + total_size: 4_096, + created_by: 'user:1', + source_type: 'github_release', + source_label: 'v1.2.2', + trigger_type: 'scheduled_auto_update', + created_at: '2026-07-19T10:00:00Z', + activated_at: null, + }; + vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]); + vi.mocked(PagesService.activateDeployment).mockResolvedValue({} as never); + + const { queryClient } = renderWithQuery( + , + ); + await screen.findByText('GitHub · v1.2.2 · 定时更新'); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + + await user.click(screen.getByRole('button', { name: '激活' })); + const dialog = screen.getByRole('alertdialog'); + expect( + within(dialog).getByText( + '激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。', + ), + ).toBeVisible(); + await user.click(within(dialog).getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(PagesService.activateDeployment).toHaveBeenCalledWith(9, 51); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: deploymentsQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: sourceQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectsQueryKey, + }); + }); + }); +}); diff --git a/frontend/tests/openflare/pages-source-ui.test.tsx b/frontend/tests/openflare/pages-source-ui.test.tsx index bdf61cc8..cf4d1383 100644 --- a/frontend/tests/openflare/pages-source-ui.test.tsx +++ b/frontend/tests/openflare/pages-source-ui.test.tsx @@ -150,7 +150,7 @@ describe('Pages source UI', () => { vi.mocked(AdminTaskService.getTaskExecution).mockReset(); }); - it('offers the three Phase 2 source types without future repository build controls', async () => { + it('offers the three source types without future repository build controls', async () => { const user = userEvent.setup(); vi.mocked(PagesService.getSource).mockResolvedValue({ source_type: 'manual', @@ -177,11 +177,13 @@ describe('Pages source UI', () => { expect( screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'), ).toBeVisible(); + expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked(); + expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60); expect(screen.queryByText('构建命令')).not.toBeInTheDocument(); expect(screen.queryByText('输出目录')).not.toBeInTheDocument(); }); - it('submits the GitHub latest payload while keeping automatic controls hidden', async () => { + it('submits the GitHub latest automatic update settings', async () => { const user = userEvent.setup(); vi.mocked(PagesService.getSource).mockResolvedValue({ source_type: 'manual', @@ -204,8 +206,10 @@ describe('Pages source UI', () => { expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue( 'dist.zip', ); - expect(screen.queryByText('自动更新')).not.toBeInTheDocument(); - expect(screen.queryByText('检查间隔')).not.toBeInTheDocument(); + await user.click(screen.getByRole('switch', { name: '自动更新' })); + const intervalInput = screen.getByLabelText('检查间隔(分钟)'); + await user.clear(intervalInput); + await user.type(intervalInput, '15'); await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); await waitFor(() => { @@ -215,8 +219,8 @@ describe('Pages source UI', () => { release_selector: 'latest', release_tag: '', asset_name: 'dist.zip', - auto_update_enabled: false, - check_interval_minutes: 60, + auto_update_enabled: true, + check_interval_minutes: 15, }); }); }); @@ -257,7 +261,8 @@ describe('Pages source UI', () => { ...githubLatestSource, release_selector: 'tag', release_tag: 'v1.2.3', - check_interval_minutes: undefined, + auto_update_enabled: false, + check_interval_minutes: 0, next_check_at: null, }, check_task: null, @@ -274,6 +279,10 @@ describe('Pages source UI', () => { 'https://github.com/openflare/site', ); await user.click(screen.getByRole('radio', { name: '固定 Tag' })); + expect( + screen.queryByRole('switch', { name: '自动更新' }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument(); await user.type( screen.getByLabelText('Release tag'), 'release/candidate#1&channel=stable', @@ -380,7 +389,7 @@ describe('Pages source UI', () => { expect(PagesService.syncSource).not.toHaveBeenCalled(); }); - it('shows GitHub release state and hides Phase 3 controls', async () => { + it('shows GitHub latest automatic update state and schedule', async () => { vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource); renderWithQuery(); @@ -389,9 +398,11 @@ describe('Pages source UI', () => { 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(); + expect(screen.getByText('下次检查时间')).toBeVisible(); + expect(screen.getByText('自动更新')).toBeVisible(); + expect(screen.getByText('已关闭')).toBeVisible(); + expect(screen.getByText('检查间隔')).toBeVisible(); + expect(screen.getByText('60 分钟')).toBeVisible(); }); it('dispatches a GitHub check and starts TaskExecution polling', async () => {