From 7b6765030bae450a44e877d7c4d41f910d52b897 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 4 Jul 2026 13:45:47 +0800 Subject: [PATCH] ci: gate demo deploy --- .github/workflows/deploy-demo.yml | 18 ++++++ playwright.demo.config.ts | 26 ++++++++ tests/e2e-demo/demo-preview.spec.ts | 61 +++++++++++++++++++ tests/unit/infra/deploy-demo-workflow.test.ts | 32 ++++++++++ 4 files changed, 137 insertions(+) create mode 100644 playwright.demo.config.ts create mode 100644 tests/e2e-demo/demo-preview.spec.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 69d88843..5c8dda54 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -13,6 +13,11 @@ on: - "turbo.json" - "tsconfig.base.json" - ".github/actions/setup/**" + - "playwright.demo.config.ts" + - "tests/e2e-demo/**" + - "tests/unit/infra/demo-theme.test.ts" + - "tests/unit/infra/demo-mock-api.test.ts" + - "tests/unit/infra/deploy-demo-workflow.test.ts" - ".github/workflows/deploy-demo.yml" workflow_dispatch: @@ -27,13 +32,26 @@ jobs: deploy: name: Deploy runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: ./.github/actions/setup + - name: Run focused demo unit tests + run: pnpm vitest run --config vitest.config.ts tests/unit/infra/demo-theme.test.ts tests/unit/infra/deploy-demo-workflow.test.ts tests/unit/infra/demo-mock-api.test.ts + + - name: Typecheck Demo + run: pnpm --filter @snapotter/demo exec tsc --noEmit + - name: Build Demo run: pnpm --filter @snapotter/demo build + - name: Install Playwright Chromium + run: pnpm playwright install --with-deps chromium + + - name: Smoke Test Demo + run: pnpm playwright test --config playwright.demo.config.ts + - name: Deploy to Cloudflare Pages run: npx wrangler pages deploy apps/demo/dist --project-name snapotter-demo --branch main --commit-dirty=true env: diff --git a/playwright.demo.config.ts b/playwright.demo.config.ts new file mode 100644 index 00000000..8ca72608 --- /dev/null +++ b/playwright.demo.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +const demoPort = 4174; +const demoBaseUrl = `http://127.0.0.1:${demoPort}`; + +export default defineConfig({ + testDir: "./tests/e2e-demo", + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: demoBaseUrl, + ...devices["Desktop Chrome"], + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: `pnpm --filter @snapotter/demo exec vite preview --host 127.0.0.1 --port ${demoPort} --strictPort`, + url: demoBaseUrl, + reuseExistingServer: false, + timeout: 60_000, + }, +}); diff --git a/tests/e2e-demo/demo-preview.spec.ts b/tests/e2e-demo/demo-preview.spec.ts new file mode 100644 index 00000000..043b14c8 --- /dev/null +++ b/tests/e2e-demo/demo-preview.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +test("demo preview uses the real app theme and reaches a tool page", async ({ page }) => { + const consoleErrors: string[] = []; + const pageErrors: string[] = []; + + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + + await page.goto("/login"); + await expect(page.getByText("This is a live demo. Processing is disabled.")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); + + const theme = await page.evaluate(() => { + const bannerLink = Array.from(document.querySelectorAll("a")).find((link) => + link.textContent?.includes("Self-host SnapOtter"), + ); + const banner = bannerLink?.closest("div"); + + return { + primary: getComputedStyle(document.documentElement) + .getPropertyValue("--color-primary") + .trim(), + themeColor: document.querySelector('meta[name="theme-color"]')?.getAttribute("content"), + bannerBackground: banner ? getComputedStyle(banner).backgroundColor : null, + }; + }); + + expect(theme.primary.toLowerCase()).toBe("#e07832"); + expect(theme.themeColor?.toLowerCase()).toBe("#e07832"); + expect(theme.bannerBackground).toBe("rgb(224, 120, 50)"); + + await page.getByLabel("Username").fill("demo"); + await page.getByLabel("Password").fill("demo"); + await page.getByRole("button", { name: /^login$/i }).click(); + + await page.waitForURL(/\/change-password$/); + await expect(page.getByRole("heading", { name: "Change your password" })).toBeVisible(); + + await page.evaluate(() => { + localStorage.setItem("snapotter-demo-state", JSON.stringify({ passwordChanged: true })); + }); + + await page.goto("/"); + const allTab = page.getByRole("button", { name: /^All\s*\d+$/ }); + await expect(allTab).toBeVisible(); + const allCount = Number((await allTab.textContent())?.match(/\d+$/)?.[0] ?? 0); + expect(allCount).toBeGreaterThan(100); + + await page.goto("/image/compress"); + await expect(page.getByRole("heading", { name: "Compress" })).toBeVisible(); + await expect(page.getByText("Drop your files here")).toBeVisible(); + await expect(page.getByRole("button", { name: "Upload from computer" })).toBeVisible(); + + expect(consoleErrors).toEqual([]); + expect(pageErrors).toEqual([]); +}); diff --git a/tests/unit/infra/deploy-demo-workflow.test.ts b/tests/unit/infra/deploy-demo-workflow.test.ts index c8568386..910db5b4 100644 --- a/tests/unit/infra/deploy-demo-workflow.test.ts +++ b/tests/unit/infra/deploy-demo-workflow.test.ts @@ -9,6 +9,15 @@ function workflowText(): string { return readFileSync(workflowPath, "utf8"); } +function expectBefore(workflow: string, earlier: string, later: string) { + const earlierIndex = workflow.indexOf(earlier); + const laterIndex = workflow.indexOf(later); + + expect(earlierIndex, `${earlier} should exist in the workflow`).toBeGreaterThanOrEqual(0); + expect(laterIndex, `${later} should exist in the workflow`).toBeGreaterThanOrEqual(0); + expect(earlierIndex, `${earlier} should run before ${later}`).toBeLessThan(laterIndex); +} + describe("demo deployment workflow", () => { it("deploys the demo app to the Cloudflare Pages demo project", () => { const workflow = workflowText(); @@ -22,6 +31,22 @@ describe("demo deployment workflow", () => { expect(workflow).toContain("CLOUDFLARE_ACCOUNT_ID: $" + "{{ secrets.CLOUDFLARE_ACCOUNT_ID }}"); }); + it("runs focused demo gates before publishing to Cloudflare", () => { + const workflow = workflowText(); + const deployCommand = + "npx wrangler pages deploy apps/demo/dist --project-name snapotter-demo --branch main --commit-dirty=true"; + + for (const gateCommand of [ + "pnpm vitest run --config vitest.config.ts tests/unit/infra/demo-theme.test.ts tests/unit/infra/deploy-demo-workflow.test.ts tests/unit/infra/demo-mock-api.test.ts", + "pnpm --filter @snapotter/demo exec tsc --noEmit", + "pnpm --filter @snapotter/demo build", + "pnpm playwright install --with-deps chromium", + "pnpm playwright test --config playwright.demo.config.ts", + ]) { + expectBefore(workflow, gateCommand, deployCommand); + } + }); + it("rebuilds when the demo shell, real app UI, shared code, or build inputs change", () => { const workflow = workflowText(); @@ -32,7 +57,14 @@ describe("demo deployment workflow", () => { '"package.json"', '"pnpm-lock.yaml"', '"pnpm-workspace.yaml"', + '"turbo.json"', + '"tsconfig.base.json"', '".github/actions/setup/**"', + '"playwright.demo.config.ts"', + '"tests/e2e-demo/**"', + '"tests/unit/infra/demo-theme.test.ts"', + '"tests/unit/infra/demo-mock-api.test.ts"', + '"tests/unit/infra/deploy-demo-workflow.test.ts"', '".github/workflows/deploy-demo.yml"', ]) { expect(workflow).toContain(pathFilter);