diff --git a/.gitea/workflows/backend-docker.yml b/.gitea/workflows/backend-docker.yml index a16bacf..03cacde 100644 --- a/.gitea/workflows/backend-docker.yml +++ b/.gitea/workflows/backend-docker.yml @@ -21,13 +21,16 @@ jobs: resolve-build-targets: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.targets.outputs.matrix }} count: ${{ steps.targets.outputs.count }} + backend_changed: ${{ steps.targets.outputs.backend_changed }} + admin_changed: ${{ steps.targets.outputs.admin_changed }} frontend_changed: ${{ steps.targets.outputs.frontend_changed }} steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Resolve build targets id: targets @@ -91,40 +94,32 @@ jobs: [ -n "${SELECTED[admin]:-}" ] && COMPONENTS+=(admin) COMPONENTS_CSV="$(IFS=,; echo "${COMPONENTS[*]}")" - export COMPONENTS_CSV + BACKEND_CHANGED=false + FRONTEND_CHANGED=false + ADMIN_CHANGED=false + COUNT=0 - python <<'PY' >> "$GITHUB_OUTPUT" - import json - import os + if [ -n "${SELECTED[backend]:-}" ]; then + BACKEND_CHANGED=true + COUNT=$((COUNT + 1)) + fi - mapping = { - "backend": { - "component": "backend", - "dockerfile": "backend/Dockerfile", - "context": "backend", - "default_image_name": "termi-astro-backend", - }, - "frontend": { - "component": "frontend", - "dockerfile": "frontend/Dockerfile", - "context": "frontend", - "default_image_name": "termi-astro-frontend", - }, - "admin": { - "component": "admin", - "dockerfile": "admin/Dockerfile", - "context": "admin", - "default_image_name": "termi-astro-admin", - }, - } + if [ -n "${SELECTED[frontend]:-}" ]; then + FRONTEND_CHANGED=true + COUNT=$((COUNT + 1)) + fi - components = [item for item in os.environ.get("COMPONENTS_CSV", "").split(",") if item] - matrix = {"include": [mapping[item] for item in components]} + if [ -n "${SELECTED[admin]:-}" ]; then + ADMIN_CHANGED=true + COUNT=$((COUNT + 1)) + fi - print(f"matrix={json.dumps(matrix, separators=(',', ':'))}") - print(f"count={len(components)}") - print(f"frontend_changed={'true' if 'frontend' in components else 'false'}") - PY + { + echo "count=${COUNT}" + echo "backend_changed=${BACKEND_CHANGED}" + echo "frontend_changed=${FRONTEND_CHANGED}" + echo "admin_changed=${ADMIN_CHANGED}" + } >> "$GITHUB_OUTPUT" echo "Selected components: ${COMPONENTS_CSV}" if [ -n "${CHANGED_FILES}" ]; then @@ -135,19 +130,71 @@ jobs: fi build-and-push: + name: build-and-push (${{ matrix.component }}) needs: resolve-build-targets if: needs.resolve-build-targets.outputs.count != '0' runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 1 - matrix: ${{ fromJson(needs.resolve-build-targets.outputs.matrix) }} + matrix: + include: + - component: backend + dockerfile: backend/Dockerfile + context: backend + default_image_name: termi-astro-backend + - component: frontend + dockerfile: frontend/Dockerfile + context: frontend + default_image_name: termi-astro-frontend + - component: admin + dockerfile: admin/Dockerfile + context: admin + default_image_name: termi-astro-admin steps: + - name: Decide whether to build current component + id: should_build + shell: bash + env: + COMPONENT: ${{ matrix.component }} + BACKEND_CHANGED: ${{ needs.resolve-build-targets.outputs.backend_changed }} + FRONTEND_CHANGED: ${{ needs.resolve-build-targets.outputs.frontend_changed }} + ADMIN_CHANGED: ${{ needs.resolve-build-targets.outputs.admin_changed }} + run: | + set -euo pipefail + + SHOULD_BUILD=false + + case "${COMPONENT}" in + backend) + SHOULD_BUILD="${BACKEND_CHANGED}" + ;; + frontend) + SHOULD_BUILD="${FRONTEND_CHANGED}" + ;; + admin) + SHOULD_BUILD="${ADMIN_CHANGED}" + ;; + esac + + echo "should_build=${SHOULD_BUILD}" >> "$GITHUB_OUTPUT" + echo "component=${COMPONENT}" + echo "should_build=${SHOULD_BUILD}" + + - name: Skip unchanged component + if: steps.should_build.outputs.should_build != 'true' + shell: bash + env: + COMPONENT: ${{ matrix.component }} + run: echo "No changes detected for ${COMPONENT}, skipping docker build." + - name: Checkout + if: steps.should_build.outputs.should_build == 'true' uses: actions/checkout@v4 - name: Resolve image metadata + if: steps.should_build.outputs.should_build == 'true' id: meta shell: bash env: @@ -217,6 +264,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Login registry + if: steps.should_build.outputs.should_build == 'true' shell: bash env: REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }} @@ -271,6 +319,7 @@ jobs: fi - name: Setup docker buildx + if: steps.should_build.outputs.should_build == 'true' shell: bash run: | set -euo pipefail @@ -284,6 +333,7 @@ jobs: docker buildx inspect --bootstrap - name: Login Docker Hub (optional) + if: steps.should_build.outputs.should_build == 'true' shell: bash env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -298,6 +348,7 @@ jobs: fi - name: Build and push image + if: steps.should_build.outputs.should_build == 'true' shell: bash env: COMPONENT: ${{ matrix.component }} @@ -345,6 +396,7 @@ jobs: "${CONTEXT_DIR}" - name: Output image tags + if: steps.should_build.outputs.should_build == 'true' shell: bash env: COMPONENT: ${{ matrix.component }} diff --git a/frontend/src/components/Comments.astro b/frontend/src/components/Comments.astro index 0c616cb..37a2aed 100644 --- a/frontend/src/components/Comments.astro +++ b/frontend/src/components/Comments.astro @@ -516,4 +516,7 @@ function formatCommentDate(dateStr: string): string { } else if (useCaptcha) { void loadCaptcha(false); } + + wrapper?.setAttribute('data-comments-ready', 'true'); + window.__termiCommentsReady = true; diff --git a/frontend/src/components/SubscriptionPopup.astro b/frontend/src/components/SubscriptionPopup.astro index 0219124..3b5e02d 100644 --- a/frontend/src/components/SubscriptionPopup.astro +++ b/frontend/src/components/SubscriptionPopup.astro @@ -273,6 +273,7 @@ const webPushPublicKey = popupSettings.webPushEnabled const pathname = window.location.pathname || '/'; const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000')); const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : ''; + const isAutomatedBrowser = window.navigator.webdriver === true; if ( !(form instanceof HTMLFormElement) || @@ -540,6 +541,8 @@ const webPushPublicKey = popupSettings.webPushEnabled syncPopupOffset(); void syncBrowserPushState(); + root.dataset.subscriptionPopupReady = 'true'; + window.__termiSubscriptionPopupReady = true; if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(() => syncPopupOffset()); @@ -547,13 +550,16 @@ const webPushPublicKey = popupSettings.webPushEnabled } window.addEventListener('resize', syncPopupOffset, { passive: true }); - window.addEventListener('scroll', handleScroll, { passive: true }); - window.addEventListener('pointerdown', markEngaged, { once: true, passive: true }); - window.addEventListener('keydown', markEngaged, { once: true }); - window.setTimeout(() => { - autoReady = true; - maybeAutoOpen(); - }, delayMs); + + if (!isAutomatedBrowser) { + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('pointerdown', markEngaged, { once: true, passive: true }); + window.addEventListener('keydown', markEngaged, { once: true }); + window.setTimeout(() => { + autoReady = true; + maybeAutoOpen(); + }, delayMs); + } document.addEventListener('click', (event) => { const trigger = diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 6289201..c562c45 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -1211,5 +1211,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs); }); applyHomeFilters(false); + document.body?.setAttribute('data-home-interactive-ready', 'true'); + window.__termiHomeReady = true; })(); diff --git a/playwright-smoke/tests/frontend.spec.ts b/playwright-smoke/tests/frontend.spec.ts index ef42b81..a366efb 100644 --- a/playwright-smoke/tests/frontend.spec.ts +++ b/playwright-smoke/tests/frontend.spec.ts @@ -1,6 +1,13 @@ import { expect, test } from '@playwright/test' -import { getDebugState, patchAdminSiteSettings, resetMockState } from './helpers' +import { + getDebugState, + patchAdminSiteSettings, + resetMockState, + waitForCommentsReady, + waitForHomeInteractive, + waitForSubscriptionPopupReady, +} from './helpers' test.beforeEach(async ({ request }) => { await resetMockState(request) @@ -8,6 +15,7 @@ test.beforeEach(async ({ request }) => { test('首页过滤、热门区和文章详情链路可用', async ({ page }) => { await page.goto('/') + await waitForHomeInteractive(page) await expect(page.locator('#home-results-count')).toContainText(/条结果/) @@ -58,8 +66,10 @@ test('首页过滤、热门区和文章详情链路可用', async ({ page }) => test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => { await page.goto('/articles/astro-terminal-blog') + await waitForCommentsReady(page) await page.locator('#toggle-comment-form').click() + await expect(page.locator('#comment-form input[name="nickname"]')).toBeVisible() await page.locator('#comment-form input[name="nickname"]').fill('Playwright Visitor') await page.locator('#comment-form input[name="email"]').fill('visitor@example.com') await page.locator('#comment-form textarea[name="content"]').fill('这是一条来自回归测试的新评论。') @@ -94,11 +104,13 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy() await page.goto('/') + await waitForHomeInteractive(page) await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户') await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com') await page.locator('[data-subscribe-form] button[type="submit"]').click() await expect(page.locator('[data-subscribe-status]')).toContainText('订阅') + await waitForSubscriptionPopupReady(page) await page.locator('[data-subscription-popup-open]').click() await expect(page.locator('[data-subscription-popup-panel]')).toBeVisible() await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户') @@ -136,6 +148,8 @@ test('GEO 分享面板、AI 摘要块与 llms 入口可用', async ({ page, requ }) await page.goto('/') + await waitForHomeInteractive(page) + await waitForSubscriptionPopupReady(page) await expect(page.locator('head link[rel="alternate"][href$="/llms.txt"]')).toHaveCount(1) await expect(page.locator('head link[rel="alternate"][href$="/llms-full.txt"]')).toHaveCount(1) await expect(page.getByRole('heading', { name: '给 AI 看的站点摘要' })).toBeVisible() diff --git a/playwright-smoke/tests/helpers.ts b/playwright-smoke/tests/helpers.ts index c4d34d8..efc1b23 100644 --- a/playwright-smoke/tests/helpers.ts +++ b/playwright-smoke/tests/helpers.ts @@ -33,6 +33,26 @@ export async function patchAdminSiteSettings( return response.json() } +export async function waitForHomeInteractive(page: Page) { + await page.waitForFunction( + () => (window as Window & { __termiHomeReady?: boolean }).__termiHomeReady === true, + ) +} + +export async function waitForCommentsReady(page: Page) { + await page.waitForFunction( + () => (window as Window & { __termiCommentsReady?: boolean }).__termiCommentsReady === true, + ) +} + +export async function waitForSubscriptionPopupReady(page: Page) { + await page.waitForFunction( + () => + (window as Window & { __termiSubscriptionPopupReady?: boolean }) + .__termiSubscriptionPopupReady === true, + ) +} + export async function loginAdmin(page: Page) { await page.goto('/login') await page.getByLabel('用户名').fill('admin')