diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bcc2988b..2f069f7aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,15 +15,28 @@ concurrency: cancel-in-progress: true jobs: - sync: - name: Sync Main to GH-Pages - uses: ./.github/workflows/sync-gh-pages.yml + guard: + name: Enforce main-only dispatch + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Verify ref is refs/heads/main + run: | + if [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "::error::Release can only be dispatched from 'main' (got ${{ github.ref }})." + exit 1 + fi + + test-website: + name: Website Testing + needs: guard + uses: ./.github/workflows/website-test.yml permissions: - contents: write + contents: read deploy: name: Deploy Site To GitHub Pages - needs: sync + needs: test-website runs-on: ubuntu-latest environment: name: github-pages @@ -31,7 +44,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ needs.sync.outputs.synced_sha }} + ref: ${{ github.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 @@ -42,14 +55,10 @@ jobs: run: | npm ci npm run build - - name: Test - working-directory: website - run: | - npm test - name: Upload Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ./website/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 \ No newline at end of file + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/sync-gh-pages.yml b/.github/workflows/sync-gh-pages.yml deleted file mode 100644 index becc67ce1..000000000 --- a/.github/workflows/sync-gh-pages.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Sync Main to GH-Pages - -# Syncs the main branch to gh-pages so that the release workflow -# can be triggered from gh-pages (required by environment protection rules). -on: - workflow_dispatch: - workflow_call: - -permissions: - contents: write - -jobs: - sync: - name: Sync Main to GH-Pages Branch - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - fetch-depth: 0 - - name: Push main to gh-pages - run: git push origin main:gh-pages --force-with-lease diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml deleted file mode 100644 index 9c027ce54..000000000 --- a/.github/workflows/test-deploy.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Test Deploy (on PR) - -on: - pull_request: - branches: - - main - # Review gh actions docs if you want to further define triggers, paths, etc - # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on - -permissions: - contents: read - -jobs: - test-deploy: - name: Test deployment - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - cache: npm - cache-dependency-path: website/package-lock.json - - name: Build website - working-directory: website - run: | - npm ci - npm run build - - name: Run Tests - working-directory: website - run: | - npm test \ No newline at end of file diff --git a/.github/workflows/website-test.yml b/.github/workflows/website-test.yml new file mode 100644 index 000000000..748bfd7e1 --- /dev/null +++ b/.github/workflows/website-test.yml @@ -0,0 +1,77 @@ +name: Website Testing + +# Runs on PRs and pushes to validate the website, and is called from +# release.yml as the pre-deploy gate. +# +# All events run: npm ci -> unit tests -> build. +# Release dispatch / manual dispatch additionally run Playwright e2e. +# PR / push runs skip the heavier browser suite to keep feedback fast. +on: + pull_request: + branches: + - main + paths: + - 'website/**' + - '.github/workflows/website-test.yml' + push: + branches: + - main + paths: + - 'website/**' + - '.github/workflows/website-test.yml' + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-website: + name: Test website + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Run unit tests + working-directory: website + run: npm test + + - name: Build website + working-directory: website + run: npm run build + + # ----- e2e steps below: only on release dispatch and manual runs ----- + + - name: Install Playwright browsers + if: ${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }} + working-directory: website + run: npx playwright install chromium --with-deps + + - name: Run Playwright tests + if: ${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }} + working-directory: website + run: npx playwright test + + - name: Upload Playwright report + if: ${{ failure() && (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: website/playwright-report/ + retention-days: 7 diff --git a/website/.gitignore b/website/.gitignore index b2d6de306..492a4c87c 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -18,3 +18,14 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Playwright +/playwright-report +/test-results +/playwright/.cache + +# Override the root `*.png` rule for the website tree. PNGs under +# website/static and any future e2e screenshot baselines should be +# tracked; PNGs inside the ignored directories above remain ignored +# (parent-dir ignore wins over file-level re-include). +!*.png diff --git a/website/e2e/gallery-filters.spec.ts b/website/e2e/gallery-filters.spec.ts index cfd66acea..952a080e9 100644 --- a/website/e2e/gallery-filters.spec.ts +++ b/website/e2e/gallery-filters.spec.ts @@ -31,14 +31,17 @@ test.describe("Gallery Filtering", () => { test("empty state shows actionable message when no results", async ({ page }) => { const searchInput = page.getByRole("searchbox"); await searchInput.fill("zzzznonexistenttemplate"); + await searchInput.press("Enter"); await expect(page.locator("text=No templates found")).toBeVisible({ timeout: 10_000 }); }); test("clear filters resets the view", async ({ page }) => { const searchInput = page.getByRole("searchbox"); await searchInput.fill("python"); + await searchInput.press("Enter"); await expect(page).toHaveURL(/name=python/); - await searchInput.clear(); + await searchInput.fill(""); + await searchInput.press("Enter"); await expect(page).not.toHaveURL(/name=python/); }); }); diff --git a/website/e2e/gallery-functionality.spec.ts b/website/e2e/gallery-functionality.spec.ts new file mode 100644 index 000000000..6667777f7 --- /dev/null +++ b/website/e2e/gallery-functionality.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from "@playwright/test"; + +// End-to-end coverage modeled on Azure/ai-app-templates' website.spec.ts, +// adapted for awesome-azd's paginated gallery (20 cards/page). Assertions +// against the filtered template *total* read the live "Viewing N templates" +// status text, since the rendered `.fui-Card` count is capped by pagination. +test.describe("Gallery functionality (deploy gate)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("./"); + await page.waitForLoadState("networkidle"); + await page.waitForSelector(".fui-Card", { timeout: 15_000 }); + }); + + // Reads the total from the "Viewing N templates" live region. The page + // has multiple aria-live polite regions (color-mode toggle, copy toast, + // status counter) so we filter by the literal "Viewing" text. + // + // The status string takes three forms depending on totalItems: + // "Viewing 0 templates" (no results) + // "Viewing 1 template" (single result) + // "Viewing 1-20 of 290 templates" (paginated, > 1 result) + // In every case the LAST integer is the total. + async function viewingCount(page: import("@playwright/test").Page): Promise { + const status = page + .locator('[role="status"]') + .filter({ hasText: /Viewing/ }) + .first(); + await expect(status).toBeVisible(); + const text = (await status.textContent()) ?? ""; + const nums = text.match(/\d+/g); + expect(nums, `expected numbers in status: "${text}"`).not.toBeNull(); + return Number(nums![nums!.length - 1]); + } + + test("homepage renders without 404 / error fallback", async ({ page }) => { + const body = (await page.textContent("body")) ?? ""; + expect(body).not.toContain("Page Not Found"); + expect(body).not.toContain("404 Not Found"); + await expect(page.locator("body")).toBeVisible(); + }); + + test("gallery shows a healthy number of templates", async ({ page }) => { + // Deploy-gate guardrail: if the data pipeline regresses or the gallery + // fails to hydrate, the live counter drops to ~0. A real release has 100+. + const total = await viewingCount(page); + console.log(`Initial gallery total: ${total}`); + expect(total).toBeGreaterThanOrEqual(50); + }); + + test("page renders at least one template card", async ({ page }) => { + const cards = page.locator(".fui-Card"); + const rendered = await cards.count(); + expect(rendered).toBeGreaterThan(0); + }); + + test('search "azure" narrows results but keeps some', async ({ page }) => { + const initial = await viewingCount(page); + expect(initial).toBeGreaterThan(0); + + const searchInput = page.locator("#filterBar"); + await searchInput.click(); + await searchInput.fill("azure"); + // The SearchBox's onSearch (URL update + filtering) only fires on Enter. + await searchInput.press("Enter"); + + await expect(page).toHaveURL(/name=azure/); + await expect + .poll(async () => viewingCount(page), { timeout: 10_000 }) + .not.toBe(initial); + + const filtered = await viewingCount(page); + console.log(`Gallery total after searching "azure": ${filtered}`); + expect(filtered).toBeGreaterThan(0); + expect(filtered).toBeLessThanOrEqual(initial); + }); + + test("nonsense search shows the empty-state message", async ({ page }) => { + const searchInput = page.locator("#filterBar"); + await searchInput.click(); + await searchInput.fill("zzzznonexistenttemplate"); + await searchInput.press("Enter"); + + await expect(page.locator("text=No templates found")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("section filter (Language → Python) narrows the gallery", async ({ + page, + }) => { + const initial = await viewingCount(page); + expect(initial).toBeGreaterThan(0); + + // The left-side filter panel uses checkboxes; clicking one applies a + // tag filter and updates the URL with ?tags=... + // Use the deterministic Fluent UI Checkbox id from ShowcaseLeftFilters + // (`showcase_checkbox_id_`) to avoid matching "Python" mentions + // inside template card titles, descriptions, or tag chips. + // ShowcaseLeftFilters has two levels of accordions: an outer one per + // section (Language, Framework, etc.) and an inner "View All" that + // hides tags after the first 6. Python is in the Language section, so + // expand that section first, then expand "View All" if needed. + const pythonCheckbox = page.locator("#showcase_checkbox_id_python"); + if (!(await pythonCheckbox.isVisible().catch(() => false))) { + await page.getByRole("button", { name: /^Language/ }).first().click(); + } + if (!(await pythonCheckbox.isVisible().catch(() => false))) { + const viewAllButtons = page.getByRole("button", { name: /view all/i }); + const count = await viewAllButtons.count(); + for (let i = 0; i < count; i++) { + await viewAllButtons.nth(i).click(); + } + } + await expect(pythonCheckbox).toBeVisible({ timeout: 10_000 }); + await pythonCheckbox.click(); + + await expect(page).toHaveURL(/tags=/); + await expect + .poll(async () => viewingCount(page), { timeout: 10_000 }) + .not.toBe(initial); + + const filtered = await viewingCount(page); + console.log(`Gallery total after filtering by Python: ${filtered}`); + expect(filtered).toBeGreaterThan(0); + expect(filtered).toBeLessThan(initial); + }); + + test("clearing search restores the full gallery", async ({ page }) => { + const initial = await viewingCount(page); + + const searchInput = page.locator("#filterBar"); + await searchInput.fill("python"); + await searchInput.press("Enter"); + await expect(page).toHaveURL(/name=python/); + await expect + .poll(async () => viewingCount(page), { timeout: 10_000 }) + .not.toBe(initial); + + await searchInput.fill(""); + await searchInput.press("Enter"); + await expect(page).not.toHaveURL(/name=python/); + await expect + .poll(async () => viewingCount(page), { timeout: 10_000 }) + .toBe(initial); + }); +}); diff --git a/website/e2e/getting-started.spec.ts b/website/e2e/getting-started.spec.ts index d7c783abb..0a6d5e2ab 100644 --- a/website/e2e/getting-started.spec.ts +++ b/website/e2e/getting-started.spec.ts @@ -3,14 +3,16 @@ import { test, expect } from "@playwright/test"; test.describe("Getting Started Page", () => { test("renders hero section", async ({ page }) => { await page.goto("getting-started"); - await expect(page.locator("h1")).toContainText("Ship to Azure", { timeout: 15_000 }); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1")).toContainText("Ship in minutes", { timeout: 15_000 }); }); test("displays three onboarding steps", async ({ page }) => { await page.goto("getting-started"); + await page.waitForLoadState("networkidle"); await expect(page.getByRole("heading", { name: "Install the Azure Developer CLI" })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole("heading", { name: "Pick a template" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Deploy to Azure" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Pick a template", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Deploy to Azure", exact: true })).toBeVisible(); }); test("shows step cards section", async ({ page }) => { diff --git a/website/e2e/homepage.spec.ts b/website/e2e/homepage.spec.ts index 9ac3839ee..8bf7afb0d 100644 --- a/website/e2e/homepage.spec.ts +++ b/website/e2e/homepage.spec.ts @@ -16,6 +16,8 @@ test.describe("Homepage", () => { const searchInput = page.getByRole("searchbox"); await expect(searchInput).toBeVisible(); await searchInput.fill("python"); + // The SearchBox's onSearch (URL update) only fires on Enter. + await searchInput.press("Enter"); await expect(page).toHaveURL(/name=python/); });