From 87269885b61eb22d64c3300e54213bb53b499d91 Mon Sep 17 00:00:00 2001 From: Brandonma21 Date: Thu, 6 Aug 2026 13:09:19 +0200 Subject: [PATCH] Set up Playwright for local, manual e2e testing (LS-2335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install Playwright via the official create-playwright scaffold shape (@playwright/test, dotenv, @types/node) and configure all 3 browser projects (Chromium, Firefox, WebKit). baseURL is read from a local, gitignored .env so each developer points tests at their own environment — no CI wiring, since there's no shared environment that reflects a PR's own code before merge; tests run manually via `npx playwright test`. Add a real spec (tests/specs/work-archive.spec.ts) exercising all 6 generic assertion helpers from tests/helpers/assertions.ts (LS-2244) against the live Work Archive template: section order, category-card count/parts, hero link href, related-routes grid reflow at mobile, and stats-grid divider styling. --- .gitignore | 7 ++ CHANGELOG.md | 1 + package-lock.json | 96 +++++++++++++++++++++++ package.json | 3 + playwright.config.ts | 84 ++++++++++++++++++++ tests/helpers/assertions.ts | 130 +++++++++++++++++++++++++++++++ tests/specs/work-archive.spec.ts | 62 +++++++++++++++ tests/specs/work-single.spec.ts | 57 ++++++++++++++ 8 files changed, 440 insertions(+) create mode 100644 playwright.config.ts create mode 100644 tests/helpers/assertions.ts create mode 100644 tests/specs/work-archive.spec.ts create mode 100644 tests/specs/work-single.spec.ts diff --git a/.gitignore b/.gitignore index 22072a1..9b616cd 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,10 @@ lightspeed.code-workspace .cache/ .aider* .continue/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a518f56..fa3fc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a portable `theme-color-token-enforcer` skill to audit or fix semantic colour token usage. - Added a theme-local `pattern-extractor` skill and matching `extract-pattern` prompt wrapper for Figma-to-pattern workflows that honour semantic tokens and CSS-versus-GSAP motion routing. - Added a CSS-only `Card - Feature` group treatment, a matching inline CTA paragraph style, and an insertable single-card pattern at `patterns/cards/card-feature.php`. +- Added a Playwright end-to-end testing setup (LS-2335), matching the official `create-playwright` scaffold: `@playwright/test`, `dotenv`, and `@types/node` as dev dependencies, and `playwright.config.ts` configured for all 3 browser projects (Chromium, Firefox, WebKit) with `baseURL` read from a local, gitignored `.env` (`BASE_URL=`) so each developer points tests at their own environment — no CI wiring, run manually via `npx playwright test`. Added a real spec (`tests/specs/work-archive.spec.ts`) exercising all 6 generic assertion helpers from `tests/helpers/assertions.ts` (LS-2244) against the live Work Archive template — section order, category-card count/parts, hero link href, related-routes grid reflow at mobile, and stats-grid divider styling. ### Changed diff --git a/package-lock.json b/package-lock.json index 7915d8b..dba1a98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "ls-theme", "version": "0.1.0", "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^26.1.2", "ajv": "^8.17.1", + "dotenv": "^17.4.2", "glob": "^11.0.1", "sass": "^1.99.0" }, @@ -336,6 +339,32 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -418,6 +447,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -459,6 +501,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -638,6 +695,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -729,6 +818,13 @@ "node": ">=0.10.0" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3725305..27e3984 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "watch:css": "npm run sync:breakpoints && sass --watch --no-source-map --no-charset --style=expanded src/scss/animations.scss:assets/css/animations.css src/scss/gsap-animations.scss:assets/css/gsap-animations.css" }, "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^26.1.2", "ajv": "^8.17.1", + "dotenv": "^17.4.2", "glob": "^11.0.1", "sass": "^1.99.0" }, diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..51204e8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,84 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import dotenv from 'dotenv'; +import path from 'path'; +// This repo's package.json has "type": "module", so __dirname (assumed by +// Playwright's own generated template) isn't available here — using +// Node's documented ESM equivalent instead: https://nodejs.org/api/esm.html#importmetadirname +dotenv.config({ path: path.resolve(import.meta.dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Deviation from the installer default ('./tests'): this repo's tests + // live under tests/specs (established in LS-2244, before this ticket). + testDir: './tests/specs', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: process.env.BASE_URL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts new file mode 100644 index 0000000..b1bb2d2 --- /dev/null +++ b/tests/helpers/assertions.ts @@ -0,0 +1,130 @@ +import { expect, type Page } from '@playwright/test'; + +/** + * Generic Playwright assertion helpers for ls-theme. + * + * None of these know about any specific page, pattern, or template — every + * input (selector, text, count, viewport width, expected value) is a + * parameter. They were extracted from testing the Work Archive template, + * but apply to any pattern in the theme with the same shape. + */ + +/** + * Asserts a list of text markers appears on the page in that exact order. + * Matching is case-insensitive so callers can write natural-case marker + * text without coupling the test to a `text-transform` CSS style. + */ +export async function expectSectionOrder(page: Page, markers: string[]) { + const bodyText = (await page.locator('body').innerText()).toLowerCase(); + const positions = markers.map((marker) => bodyText.indexOf(marker.toLowerCase())); + + positions.forEach((pos, i) => { + expect(pos, `Expected to find "${markers[i]}" on the page`).toBeGreaterThan(-1); + }); + + for (let i = 1; i < positions.length; i++) { + expect( + positions[i], + `Expected "${markers[i]}" to appear after "${markers[i - 1]}"` + ).toBeGreaterThan(positions[i - 1]); + } +} + +/** Asserts a selector matches exactly `count` elements. */ +export async function expectElementCount(page: Page, selector: string, count: number) { + await expect(page.locator(selector)).toHaveCount(count); +} + +/** + * Asserts every element matching `cardSelector` contains all of its + * `requiredParts` (each a sub-selector, e.g. 'a', '.wp-block-post-excerpt'). + */ +export async function expectCardParts(page: Page, cardSelector: string, requiredParts: string[]) { + const cards = page.locator(cardSelector); + const count = await cards.count(); + expect(count, `Expected at least one "${cardSelector}" card`).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + for (const part of requiredParts) { + await expect( + card.locator(part).first(), + `Card ${i} (${cardSelector}) is missing required part "${part}"` + ).toBeAttached({ timeout: 2000 }); + } + } +} + +/** Asserts a link, found by its visible text, has the expected href. */ +export async function expectLinkHref(page: Page, linkText: string, expectedHref: string) { + const link = page.getByRole('link', { name: linkText, exact: false }); + await expect(link).toHaveAttribute('href', expectedHref); +} + +/** + * Resizes the viewport to `viewportWidth` and asserts the grid at + * `gridSelector` shows exactly `expectedColumns` columns — works for both + * CSS Grid (reads grid-template-columns) and flex-wrap layouts (groups + * elements by their top offset). + */ +export async function expectGridColumnsAtViewport( + page: Page, + gridSelector: string, + viewportWidth: number, + expectedColumns: number, + viewportHeight = 900 +) { + await page.setViewportSize({ width: viewportWidth, height: viewportHeight }); + + const grid = page.locator(gridSelector).first(); + const display = await grid.evaluate((el) => getComputedStyle(el).display); + + if (display === 'grid') { + const columns = await grid.evaluate( + (el) => getComputedStyle(el).gridTemplateColumns.split(' ').length + ); + expect(columns, `Expected ${expectedColumns} grid columns at ${viewportWidth}px`).toBe( + expectedColumns + ); + return; + } + + // Flex/columns layout: count distinct items sharing the first row's top offset. + const tops: number[] = await grid.evaluate((el) => + [...el.children].map((child) => Math.round(child.getBoundingClientRect().top)) + ); + const firstRowCount = tops.filter((t) => t === tops[0]).length; + expect( + firstRowCount, + `Expected ${expectedColumns} items in the first row at ${viewportWidth}px` + ).toBe(expectedColumns); +} + +/** + * Asserts a computed CSS property on `selector` equals `expectedValue`, + * optionally after resizing to `viewportWidth` first. + */ +export async function expectComputedStyle( + page: Page, + selector: string, + property: string, + expectedValue: string, + viewportWidth?: number, + viewportHeight = 900 +) { + if (viewportWidth) { + await page.setViewportSize({ width: viewportWidth, height: viewportHeight }); + } + + const value = await page + .locator(selector) + .first() + .evaluate((el, prop) => getComputedStyle(el).getPropertyValue(prop).trim(), property); + + expect( + value, + `Expected ${selector} to have ${property}: ${expectedValue}${ + viewportWidth ? ` at ${viewportWidth}px` : '' + }` + ).toBe(expectedValue); +} diff --git a/tests/specs/work-archive.spec.ts b/tests/specs/work-archive.spec.ts new file mode 100644 index 0000000..6067350 --- /dev/null +++ b/tests/specs/work-archive.spec.ts @@ -0,0 +1,62 @@ +import { test } from '@playwright/test'; +import { + expectSectionOrder, + expectElementCount, + expectCardParts, + expectLinkHref, + expectGridColumnsAtViewport, + expectComputedStyle, +} from '../helpers/assertions'; + +// The Work Archive page's own slug is confirmed as /work/ on dev +// (https://ls-agency.lightspeedwp.dev/work/) but can differ per environment +// (e.g. /portfolio/ on some local setups) — override via env var if needed. +const WORK_ARCHIVE_URL = process.env.WORK_ARCHIVE_PATH || '/work/'; + +test.describe('Work Archive template', () => { + test('renders sections in order', async ({ page }) => { + await page.goto(WORK_ARCHIVE_URL); + await expectSectionOrder(page, [ + 'Work · Proof · Outcomes', + 'Three recurring areas of work.', + 'Selected Projects', + 'Across every engagement.', + 'Ready to discuss a project?', + 'Related routes', + ]); + }); + + test('shows exactly 3 work category cards', async ({ page }) => { + await page.goto(WORK_ARCHIVE_URL); + await expectElementCount(page, '.is-style-card-category', 3); + }); + + test('each work category card has its required parts', async ({ page }) => { + await page.goto(WORK_ARCHIVE_URL); + await expectCardParts(page, '.is-style-card-category', [ + 'h3', + 'p', + '.is-style-link-arrow-accent a', + ]); + }); + + test('the hero CTA links to the consultation page', async ({ page, baseURL }) => { + await page.goto(WORK_ARCHIVE_URL); + // "Book a consultation" text also appears (with a trailing "→") in the + // Discuss Project section further down, so use the hero-only + // "Explore case studies" link to keep the match unambiguous. WP renders + // home_url() links as absolute URLs, so the expected href must match + // the full origin too. + await expectLinkHref(page, 'Explore case studies', `${baseURL}/work/`); + }); + + test('related routes grid reflows from multiple columns to 1 on mobile', async ({ page }) => { + await page.goto(WORK_ARCHIVE_URL); + await expectGridColumnsAtViewport(page, '.wp-block-group:has(> .is-style-card-link-row)', 375, 1); + }); + + test('work category cards keep their divider styling', async ({ page }) => { + await page.goto(WORK_ARCHIVE_URL); + await expectComputedStyle(page, '.is-style-card-divider-both', 'display', 'flex'); + }); +}); diff --git a/tests/specs/work-single.spec.ts b/tests/specs/work-single.spec.ts new file mode 100644 index 0000000..f70a257 --- /dev/null +++ b/tests/specs/work-single.spec.ts @@ -0,0 +1,57 @@ +import { test } from '@playwright/test'; +import { + expectSectionOrder, + expectElementCount, + expectCardParts, + expectLinkHref, + expectGridColumnsAtViewport, + expectComputedStyle, +} from '../helpers/assertions'; + +// ILLUSTRATIVE — selectors below are guesses at what work-single will look +// like, based on today's conventions (breadcrumbs, eyebrow badge, CTA +// buttons, a related-projects card row). Swap these for the real selectors +// once the pattern exists. This file exists to show the *shape* of how the +// 6 helpers get used, not to test anything real yet. + +const SINGLE_PROJECT_URL = '/portfolio/african-safari-consultants/'; + +// Skipped: work-single doesn't exist yet, and these selectors/URL are illustrative +// guesses, not real markup. Un-skip once the template is built and selectors are +// swapped for the real ones. +test.describe.skip('Work Single template', () => { + test('renders sections in order', async ({ page }) => { + await page.goto(SINGLE_PROJECT_URL); + await expectSectionOrder(page, ['Home', 'Portfolio', 'Related projects']); + }); + + test('shows exactly 3 related-project cards', async ({ page }) => { + await page.goto(SINGLE_PROJECT_URL); + await expectElementCount(page, '.is-style-card-case-study', 3); + }); + + test('each related-project card has its required parts', async ({ page }) => { + await page.goto(SINGLE_PROJECT_URL); + await expectCardParts(page, '.is-style-card-case-study', [ + '.wp-block-post-title a', + '.wp-block-post-excerpt', + 'text=View project', + ]); + }); + + test('the CTA button links to the consultation page', async ({ page }) => { + await page.goto(SINGLE_PROJECT_URL); + await expectLinkHref(page, 'Book a consultation', '/free-consultation/'); + }); + + test('related-projects grid reflows from 3 to 1 column on mobile', async ({ page }) => { + await page.goto(SINGLE_PROJECT_URL); + await expectGridColumnsAtViewport(page, '.related-projects-grid', 1280, 3); + await expectGridColumnsAtViewport(page, '.related-projects-grid', 375, 1); + }); + + test('the single-project hero keeps its bottom divider border', async ({ page }) => { + await page.goto(SINGLE_PROJECT_URL); + await expectComputedStyle(page, '.work-single-hero', 'border-bottom-width', '1px'); + }); +});