diff --git a/.gitignore b/.gitignore index 57f582d..1207fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,7 @@ vendor Gemfile.lock _data/token.yml + +# Output from automated tests +/playwright-report +/test-results diff --git a/_config.yml b/_config.yml index 393f475..fd8daf2 100644 --- a/_config.yml +++ b/_config.yml @@ -25,8 +25,12 @@ exclude: - docker-compose.yml - Gruntfile.js - dist/ + - e2e/ + - playwright-report/ + - test-results/ - package.json - package-lock.json + - playwright.config.js - LICENSE - CONDE_OF_CONDUCT.md - CONTRIBUTING.md diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..3f615da --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,155 @@ +# Playwright end-to-end tests + +This folder contains automated browser tests for the Canada.ca Search UI. + +## Recommended file structure + +Keep test files organized by user workflow or page type. + +Suggested starting point: + +```text +e2e/ + README.md + search.spec.js + query-suggestions.spec.js + advanced-search.spec.js + contextual-search.spec.js + templates.spec.js + analytics.spec.js +``` + +If repeated setup becomes noisy, add small helper files under `e2e/support/`. Good helper candidates: + +- `goToTestPage(page, 'qs-en.html')`; +- `searchInput(page)`; +- `submitSearch(page)`; +- mock of Coveo requests/responses (if specific results are needed) + +Start simple and add as needed, when it removes meaningful repetition. + +## Writing tests + +Add `.spec.js` files to this directory. Playwright picks them up automatically. + +```js +// @ts-check +const { test, expect } = require('@playwright/test'); + +test('shows the search form', async ({ page }) => { + await page.goto('/tests/qs-en.html'); + + await expect(page.getByLabel('Search Government of Canada websites')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Search' })).toBeVisible(); +}); +``` + +Prefer: + +- `getByRole`, `getByLabel`, `getByText`, and `getByPlaceholder`; +- web-first assertions such as `await expect(locator).toBeVisible()`; +- one user behaviour per test; +- test names that describe the expected behaviour; +- small setup repeated in each test when it makes the test clearer; +- `test.beforeEach` to reset the page state between tests. + +Avoid: + +- brittle CSS selectors when an accessible locator is available; +- testing internal functions from `src/connector.js`; +- use `page.waitForResponse` to wait for network requests to complete before performing tests. i.e., + - `await page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }).catch(() => null);` +- for other fixed sleeps such as `waitForTimeout`; +- tests that depend on another test running first; +- broad tests that cover many behaviours at once; + +## Getting going + +### Install dependencies: + +```bash +npm install +npx playwright install chromium +``` + +### Start environment + +The README at the repo root explains how to start the Docker-based local environment, using a valid search token. + +The Playwright config expects: + +```text +http://localhost:4000 +``` + +## Running tests + +Most tests can be run via `npm`: + +Run all tests, silently: + +```bash +npm test +``` + +Run the interactive Playwright UI: + +```bash +npm run test:ui +``` + +Open the last HTML report: + +```bash +npm run test:report +``` + +Run one file: + +```bash +npx playwright test e2e/search.spec.js +``` + +Run one test by title: + +```bash +npx playwright test -g "renders the search form" +``` + +## Configuration + +The Playwright config lives at `playwright.config.js`. + +Current defaults: + +- tests are read from `e2e/` +- the base URL is `http://localhost:4000` +- tests are run in Chromium/Playwright UI +- generated artifacts such as `playwright-report/` have been excluded from version control and the Jekyll build process + +Optional configuration improvements: + +- add Firefox and WebKit projects once the Chromium suite is stable; + +## Debugging test failures + +Use the Playwright UI while writing, debugging, or to review actual browser results: + +```bash +npm run test:ui +``` + +Use trace mode when a failure needs a step-by-step replay: + +```bash +npx playwright test --trace on +npm run test:report +``` + +The trace viewer is useful for failures because it shows the DOM, console, network requests, actions, and assertions around the failure. + +## References + +- [Playwright best practices](https://playwright.dev/docs/best-practices) +- [Playwright configuration](https://playwright.dev/docs/test-configuration) +- [Playwright continuous integration](https://playwright.dev/docs/ci) diff --git a/e2e/qs-en.spec.js b/e2e/qs-en.spec.js new file mode 100644 index 0000000..2538448 --- /dev/null +++ b/e2e/qs-en.spec.js @@ -0,0 +1,111 @@ +import { test, expect } from '@playwright/test'; + +test.describe('QS EN page', () => { + test.beforeEach(async ({ page }) => { + // Mask the automation flag that Playwright sets — Coveo detects it and skips initialization. + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + await page.goto('http://localhost:4000/tests/srb-en.html'); + // await page.goto('http://localhost:4000/tests/qs-en.html'); + }); + + test('query suggestion UI is initialized', async ({ page }) => { + await page.goto('http://localhost:4000/tests/srb-en.html'); + + const searchBox = page.locator('#sch-inp-ac'); + await expect(searchBox).toHaveAttribute('type', 'text'); + await expect(searchBox).toHaveAttribute('role', 'combobox'); + await expect(searchBox).toHaveAttribute('aria-expanded', 'false'); + await expect(searchBox).toHaveAttribute('aria-autocomplete', 'list'); + await expect(searchBox).toHaveAttribute('aria-controls', 'suggestions'); + + const suggestionsList = page.locator('form[role="search"] ul#suggestions'); + await expect(suggestionsList).toHaveAttribute('role', 'listbox'); + await expect(suggestionsList).toHaveClass(/query-suggestions/); + await expect(suggestionsList).toHaveAttribute('aria-describedby', 'sr-qs-hint'); + + const hint = page.locator('form[role="search"] p#sr-qs-hint'); + await expect(hint).toHaveClass(/hidden/); + }); + + test('query suggestions appear and update while typing, then disappear when input is too short', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + await searchBox.focus(); + + // Type "canada" one character at a time, asserting suggestions only appear at 3+ characters. + for (const [i, char] of [...'canada'].entries()) { + // Wait for the suggestions API response before asserting UI state. + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }).catch(() => null); + await page.keyboard.type(char); + await responsePromise; + const typedSoFar = 'canada'.slice(0, i + 1); + + if (typedSoFar.length < 3) { + // Fewer than 3 characters — suggestions should not be shown. + await expect(searchBox).toHaveAttribute('aria-expanded', 'false'); + await expect(suggestionsList).toHaveAttribute('hidden'); + + } else { + // 3 or more characters — wait for suggestions to load and verify count. + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + await expect(suggestionItems.first()).toBeVisible(); + const count = await suggestionItems.count(); + expect(count, `expected 1–10 suggestions for "${typedSoFar}"`).toBeGreaterThanOrEqual(1); + expect(count, `expected 1–10 suggestions for "${typedSoFar}"`).toBeLessThanOrEqual(10); + } + } + + // Backspace one character at a time. Suggestions should stay visible until input drops below 3 characters. + for (let remaining = 'canada'.length - 1; remaining >= 0; remaining--) { + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }).catch(() => null); + await page.keyboard.press('Backspace'); + await responsePromise; + + if (remaining >= 3) { + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + await expect(suggestionItems.first()).toBeVisible(); + } else { + // Input is now 0–2 characters — suggestions should be hidden. + await expect(searchBox).toHaveAttribute('aria-expanded', 'false'); + await expect(suggestionsList).toHaveAttribute('hidden'); + } + } + }); + + test('clicking a query suggestion submits a search for that suggestion', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + await searchBox.focus(); + + // Wait for suggestions to load before clicking. + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('canada'); + await responsePromise; + + await expect(suggestionItems.first()).toBeVisible(); + const secondSuggestion = suggestionItems.nth(1); + const suggestionText = await secondSuggestion.innerText(); + await secondSuggestion.click(); + + // The suggestions box should close after clicking. + await expect(suggestionsList).toHaveAttribute('hidden'); + await expect(searchBox).toHaveAttribute('aria-expanded', 'false'); + + // The search field should show the clicked suggestion's text. + await expect(searchBox).toHaveValue(suggestionText); + + // Results should be returned for the selected suggestion. + const summary = page.locator('#wb-land h2'); + await expect(summary).toBeVisible(); + await expect(summary).toContainText(suggestionText); + }); +}); diff --git a/e2e/srb-en.spec.js b/e2e/srb-en.spec.js new file mode 100644 index 0000000..c2ef70b --- /dev/null +++ b/e2e/srb-en.spec.js @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; + +test.describe('SRB EN page', () => { + test.beforeEach(async ({ page }) => { + // Mask the automation flag that Playwright sets — Coveo detects it and skips initialization. + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + await page.goto('http://localhost:4000/tests/srb-en.html'); + }); + + test('search box and config are present', async ({ page }) => { + await expect(page.locator('#sch-inp-ac')).toBeVisible(); + + // connector.js reads the data-gc-search attribute to configure the Coveo search engine. + const configEl = page.locator('[data-gc-search]'); + await expect(configEl).toBeAttached(); + const attrValue = await configEl.getAttribute('data-gc-search'); + expect(attrValue).toBeTruthy(); + }); + + test('search library initializes successfully', async ({ page, context }) => { + // A query in the URL hash is required to trigger Coveo's first API call, + // which is what sets the tracking cookie and localStorage item. + await page.goto('http://localhost:4000/tests/srb-en.html#q=canada'); + + await page.waitForFunction( + () => document.cookie.includes('coveo_visitorId') || window.__coveoInitialized, + { timeout: 5000 } + ).catch(() => {}); + + // Fetch cookies via the browser context rather than JS so HttpOnly cookies are included. + const cookies = await context.cookies('http://localhost:4000'); + const visitorCookie = cookies.find(c => c.name === 'coveo_visitorId'); + expect(visitorCookie, 'coveo_visitorId cookie should exist').toBeTruthy(); + + await page.waitForFunction(() => localStorage.getItem('visitorId') !== null, { timeout: 5000 }); + const visitorId = await page.evaluate(() => localStorage.getItem('visitorId')); + expect(visitorId, 'visitorId localStorage item should exist').toBeTruthy(); + }); + + test('basic keyword search via keyboard submit', async ({ page }) => { + await page.locator('#sch-inp-ac').focus(); + await page.keyboard.type('Canada'); + await page.keyboard.press('Enter'); + + const summary = page.locator('#wb-land h2'); + await expect(summary).toBeFocused(); + await expect(summary).toBeVisible(); + await expect(summary).toContainText('Canada'); + + // After keyboard interaction, the browser shows a visible focus ring (:focus-visible is true). + const hasFocusRing = await summary.evaluate(el => el.matches(':focus-visible')); + expect(hasFocusRing, 'focus ring should be visible after keyboard submit').toBe(true); + + await expect(page.locator('#sch-inp-ac')).toHaveValue('Canada'); + }); + + test('basic keyword search via mouse submit', async ({ page }) => { + await page.locator('#sch-inp-ac').click(); + await page.keyboard.type('Canada'); + await page.locator('form[role="search"] button[type="submit"]').click(); + + const summary = page.locator('#wb-land h2'); + await expect(summary).toBeFocused(); + await expect(summary).toBeVisible(); + await expect(summary).toContainText('Canada'); + + // After mouse interaction, the browser suppresses the focus ring (:focus-visible is false). + const hasFocusRing = await summary.evaluate(el => el.matches(':focus-visible')); + expect(hasFocusRing, 'focus ring should not be visible after mouse submit').toBe(false); + + await expect(page.locator('#sch-inp-ac')).toHaveValue('Canada'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 02b2899..51fafc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "(MIT AND Apache-2.0)", "devDependencies": { "@lodder/grunt-postcss": "^3.0.1", + "@playwright/test": "^1.60.0", "grunt": "^1.6.1", "grunt-banner": "^0.6.0", "grunt-contrib-clean": "^2.0.1", @@ -1541,6 +1542,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2737,6 +2754,21 @@ "dev": true, "license": "ISC" }, + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4326,6 +4358,38 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/package.json b/package.json index 7c3649d..4fff163 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,14 @@ "author": "ServiceCanada", "license": "(MIT AND Apache-2.0)", "homepage": "https://servicecanada.github.io/search-ui/", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report" + }, "devDependencies": { + "@lodder/grunt-postcss": "^3.0.1", + "@playwright/test": "^1.60.0", "grunt": "^1.6.1", "grunt-banner": "^0.6.0", "grunt-contrib-clean": "^2.0.1", @@ -26,7 +33,6 @@ "grunt-eslint": "^24.3.0", "grunt-htmllint": "^0.3.0", "postcss": "^8.5.8", - "postcss-preset-env": "^11.2.0", - "@lodder/grunt-postcss": "^3.0.1" + "postcss-preset-env": "^11.2.0" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..4bc6f21 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,27 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './e2e', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: 'html', + use: { + baseURL: 'http://localhost:4000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: [ + '--disable-features=ImprovedCookieControls,ImprovedCookieControlsForThirdPartyCookieBlocking,SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure,CookiesWithoutSameSiteMustBeSecure,SameSiteByDefaultCookies', + '--disable-blink-features=AutomationControlled', + ], + }, + }, + }, + ], +});