diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..560cb5f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,191 @@ +name: Tests + +on: + push: + branches: + - main + - 'feature/**' + - 'fix/**' + - 'ci/**' + pull_request: + schedule: + # Nightly: this repository ships by tag, so a framework released after this + # theme was tagged can break it without a single commit landing here. + - cron: '0 5 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + browser: + name: Browser (framework ${{ matrix.framework }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + if: github.repository == 'Pollora/theme-buzz' + + # locked: the framework a new project installs, which is the skeleton's lock. + # develop: the next framework release, before it ships. + strategy: + fail-fast: false + matrix: + framework: [locked, develop] + + steps: + - name: Checkout the theme + uses: actions/checkout@v6 + with: + path: theme + + - name: Setup DDEV + uses: ddev/github-action-setup-ddev@v1 + with: + autostart: false + + # Pinned to the beta on purpose: with no version, create-project installs + # the last *stable* skeleton (v13.4.0), which predates block-theme support. + - name: Create the skeleton + run: | + composer create-project pollora/pollora site '^13.32@beta' \ + --no-install --no-scripts --no-interaction + + - name: Configure DDEV + working-directory: site + run: | + ddev config \ + --project-name=buzz-ci \ + --project-type=wordpress \ + --docroot=public \ + --php-version=8.4 \ + --webserver-type=apache-fpm \ + --database=mariadb:10.11 \ + --nodejs-version=22 \ + --disable-settings-management + + - name: Start DDEV + working-directory: site + run: ddev start + + - name: Prepare .env + working-directory: site + run: | + cp .env.example .env + sed -i \ + -e 's#^APP_URL=.*#APP_URL=https://buzz-ci.ddev.site#' \ + -e 's#^DB_CONNECTION=.*#DB_CONNECTION=mysql#' \ + -e 's/^# DB_HOST=.*/DB_HOST=db/' \ + -e 's/^# DB_PORT=.*/DB_PORT=3306/' \ + -e 's/^# DB_DATABASE=.*/DB_DATABASE=db/' \ + -e 's/^# DB_USERNAME=.*/DB_USERNAME=db/' \ + -e 's/^# DB_PASSWORD=.*/DB_PASSWORD=db/' \ + .env + + - name: Install Composer dependencies + working-directory: site + run: ddev composer install --no-interaction --no-progress + + - name: Use framework develop + if: matrix.framework == 'develop' + working-directory: site + run: ddev composer require pollora/framework:dev-develop --with-all-dependencies --no-interaction --no-progress + + - name: Generate application key + working-directory: site + run: ddev exec php artisan key:generate --no-interaction + + # --no-scripts: this only replaces core files, and the skeleton's + # post-autoload-dump hook opens a database connection that has no TTY to + # fall back on. + - name: Use the latest WordPress + working-directory: site + run: | + ddev composer update johnpbloch/wordpress --with-all-dependencies \ + --no-scripts --no-interaction --no-progress + + - name: Check the WordPress core is complete + working-directory: site + run: | + ddev exec test -f public/cms/wp-includes/l10n.php \ + || { echo "::error::WordPress core is incomplete — wp-includes is missing."; exit 1; } + ddev exec wp core version + + - name: Install WordPress + working-directory: site + run: | + ddev exec php artisan pollora:install --install \ + --title="Buzz CI" \ + --description="Pollora theme-buzz CI" \ + --admin-user=admin \ + --admin-email=admin@example.com \ + --admin-password=buzz-ci \ + --locale=en_US \ + --public=false \ + --no-interaction || true + ddev exec wp core is-installed + + # The commit under test, copied as it is: Buzz carries no scaffolding + # placeholders. The directory name matters — the Vite build folder is + # named after it. + - name: Install and build the theme + working-directory: site + run: | + cp -r ../theme themes/buzz + rm -rf themes/buzz/.git themes/buzz/bin themes/buzz/.github + ddev exec bash -c 'cd themes/buzz && npm install --no-audit --no-fund && npm run build' + ddev exec wp theme activate buzz + ddev exec wp rewrite flush + + - name: Fetch the framework's browser test harness + working-directory: site + run: | + # develop is aliased (13.x-dev), so dev-develop is not always the first version listed. + ref=$(ddev composer show pollora/framework --format=json \ + | jq -r '.versions | if index("dev-develop") then "develop" else .[0] end') + echo "Harness from pollora/framework@$ref" + git clone --quiet --depth 1 --branch "$ref" https://github.com/Pollora/framework.git ../framework + mkdir -p ../framework/tests/e2e/specs/buzz + cp ../theme/bin/tests/e2e/*.spec.ts ../framework/tests/e2e/specs/buzz/ + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Install the browser tests + working-directory: framework/tests/e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Trust the site's certificate + run: | + if command -v mkcert >/dev/null && [ -f "$(mkcert -CAROOT)/rootCA.pem" ]; then + echo "NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem" >> "$GITHUB_ENV" + else + echo "::warning::No mkcert authority on the runner; certificate checks are off for this disposable site" + echo "NODE_TLS_REJECT_UNAUTHORIZED=0" >> "$GITHUB_ENV" + fi + + - name: Browser tests + working-directory: framework/tests/e2e + env: + E2E_HOME_URL: https://buzz-ci.ddev.site + E2E_SITE_DIR: ${{ github.workspace }}/site + E2E_RUN_ID: ${{ github.run_id }} + run: npx playwright test specs/buzz + + - name: Upload the browser test report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: e2e-report-${{ matrix.framework }} + path: | + framework/tests/e2e/playwright-report + framework/tests/e2e/test-results + if-no-files-found: ignore + + - name: Laravel log + if: failure() + working-directory: site + run: tail -n 200 storage/logs/laravel.log || true diff --git a/app/Providers/AssetServiceProvider.php b/app/Providers/AssetServiceProvider.php index 913b795..4aba057 100644 --- a/app/Providers/AssetServiceProvider.php +++ b/app/Providers/AssetServiceProvider.php @@ -22,6 +22,7 @@ public function boot(): void Asset::add('buzz/script', 'app.js') ->container('theme') ->toFrontend() + ->loadInFooter() ->useVite(); } } diff --git a/bin/tests/e2e/README.md b/bin/tests/e2e/README.md new file mode 100644 index 0000000..d14bd9a --- /dev/null +++ b/bin/tests/e2e/README.md @@ -0,0 +1,9 @@ +# Browser tests + +Playwright specs for this theme, run on the framework's E2E harness: CI checks out +`pollora/framework`'s `tests/e2e` at the version the site runs, copies these files to +`specs/buzz/` and runs them there (so they import `../../support/site`). + +The spec creates the content it needs with WP-CLI and deletes it afterwards. The theme +must be the active one. `bin/` is stripped by the scaffolder: none of this reaches a +project generated from Buzz. diff --git a/bin/tests/e2e/buzz.spec.ts b/bin/tests/e2e/buzz.spec.ts new file mode 100644 index 0000000..20d28e0 --- /dev/null +++ b/bin/tests/e2e/buzz.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; +import { homeUrl, renderedTemplate, runId, wp } from '../../support/site'; + +/** + * Buzz is a block theme: WordPress resolves templates/*.html itself, so the framework's + * marker names the canvas (`template-canvas`), never a Blade view. What tells the + * templates apart is the body class WordPress writes for the request, and what each + * template's pattern puts on the page. + */ + +const tag = `buzz-e2e-${runId}`; +const ids: Record = {}; +const urls: Record = {}; + +test.beforeAll(() => { + const create = (...args: string[]): string => wp('post', 'create', '--post_status=publish', '--porcelain', ...args); + const link = (id: string): string => new URL(wp('post', 'get', id, '--field=url')).pathname; + + const category = wp('term', 'create', 'category', `${tag}-cat`, '--porcelain'); + ids.post = create('--post_type=post', `--post_title=Headline ${tag}`, `--post_content=

Body ${tag}

`, `--post_category=${category}`); + ids.page = create('--post_type=page', `--post_title=Page ${tag}`, `--post_content=

Page body ${tag}

`); + ids.category = category; + + urls.post = link(ids.post); + urls.page = link(ids.page); + urls.category = new URL(wp('term', 'get', 'category', category, '--field=url')).pathname; +}); + +test.afterAll(() => { + for (const key of ['post', 'page']) { + if (ids[key]) { + wp('post', 'delete', ids[key], '--force'); + } + } + + if (ids.category) { + wp('term', 'delete', 'category', ids.category); + } +}); + +async function visit(page: import('@playwright/test').Page, path: string, status = 200): Promise { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + const response = await page.goto(homeUrl(path)); + + expect(response?.status(), `status of ${path}`).toBe(status); + expect(errors, 'no uncaught page error').toEqual([]); + + return page.content(); +} + +test('the theme under test is Buzz, a block theme', () => { + expect(wp('theme', 'list', '--status=active', '--field=name')).toBe('buzz'); + expect(wp('eval', 'echo wp_is_block_theme() ? "yes" : "no";')).toBe('yes'); +}); + +test('the masthead and the colophon frame every template', async ({ page }) => { + for (const path of ['/', urls.post, urls.page, '/?s=nothing', '/no-such-page-' + tag]) { + await visit(page, path, path.startsWith('/no-such') ? 404 : 200); + await expect(page.locator('header.buzz-masthead .buzz-nameplate'), path).toBeVisible(); + await expect(page.locator('footer.buzz-colophon'), path).toContainText(String(new Date().getFullYear())); + } +}); + +test('the front page lists the posts', async ({ page }) => { + const html = await visit(page, '/'); + + expect(renderedTemplate(html)?.template).toBe('template-canvas'); + await expect(page.locator('body')).toHaveClass(/\bhome\b/); + await expect(page.locator('main .buzz-index-list')).toContainText(`Headline ${tag}`); +}); + +test('a post renders its title and body', async ({ page }) => { + await visit(page, urls.post); + + await expect(page.locator('body')).toHaveClass(/\bsingle-post\b/); + await expect(page.locator('main h1')).toContainText(`Headline ${tag}`); + await expect(page.locator('main')).toContainText(`Body ${tag}`); +}); + +test('a page renders its title and body', async ({ page }) => { + await visit(page, urls.page); + + await expect(page.locator('body')).toHaveClass(/\bpage\b/); + await expect(page.locator('main h1')).toContainText(`Page ${tag}`); + await expect(page.locator('main')).toContainText(`Page body ${tag}`); +}); + +test('a category archive lists its posts under its own title', async ({ page }) => { + await visit(page, urls.category); + + await expect(page.locator('body')).toHaveClass(/\bcategory\b/); + await expect(page.locator('main .wp-block-query-title')).toContainText(`${tag}-cat`); + await expect(page.locator('main .buzz-index-list')).toContainText(`Headline ${tag}`); +}); + +test('a search finds a post, and says so when it finds nothing', async ({ page }) => { + await visit(page, `/?s=${encodeURIComponent(`Headline ${tag}`)}`); + await expect(page.locator('body')).toHaveClass(/\bsearch-results\b/); + await expect(page.locator('main .buzz-index-list')).toContainText(`Headline ${tag}`); + + await visit(page, `/?s=zzz-no-match-${runId}`); + await expect(page.locator('body')).toHaveClass(/\bsearch-no-results\b/); + await expect(page.locator('main')).toContainText('Nothing here yet.'); +}); + +test("an unknown URL renders the theme's 404, with a 404 status", async ({ page }) => { + await visit(page, `/no-such-page-${tag}`, 404); + + await expect(page.locator('body')).toHaveClass(/\berror404\b/); + await expect(page.locator('main h1')).toHaveText('Page missing'); + await expect(page.locator('main .buzz-index-list')).toHaveCount(0); +}); + +test('the design-system pattern is offered to authors', () => { + const inserter = wp('eval', 'echo WP_Block_Patterns_Registry::get_instance()->is_registered("buzz/design-system") ? "yes" : "no";'); + + expect(inserter).toBe('yes'); +}); + +test.describe('on a phone', () => { + test.use({ viewport: { width: 375, height: 700 } }); + + test('the navigation collapses behind a button that opens and closes it', async ({ page }) => { + await visit(page, '/'); + + const open = page.locator('.wp-block-navigation__responsive-container-open'); + const menu = page.locator('.wp-block-navigation__responsive-container'); + + await expect(open).toBeVisible(); + await expect(menu).not.toHaveClass(/\bis-menu-open\b/); + + await open.click(); + await expect(menu).toHaveClass(/\bis-menu-open\b/); + await expect(menu.getByRole('link', { name: `Page ${tag}` })).toBeVisible(); + + await page.locator('.wp-block-navigation__responsive-container-close').click(); + await expect(menu).not.toHaveClass(/\bis-menu-open\b/); + }); + + test('nothing overflows the viewport sideways', async ({ page }) => { + for (const path of ['/', urls.post]) { + await visit(page, path); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow, `horizontal overflow on ${path}`).toBeLessThanOrEqual(0); + } + }); +});