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/fixtures/result-template-results.json b/e2e/fixtures/result-template-results.json new file mode 100644 index 0000000..42e4c41 --- /dev/null +++ b/e2e/fixtures/result-template-results.json @@ -0,0 +1,1140 @@ +{ + "advancedExpression": null, + "apiVersion": 2, + "basicExpression": "benefits", + "categoryFacets": [], + "constantExpression": "@declared_language==(eng,en,en-CA) OR (NOT @declared_language @language=english)", + "disjunctionExpression": "@permanentid=8474a65cdb75baf0af880228aaae5e8a374fff804e0d571fc41dce1734bb OR @permanentid=e0002bdd6d3c6f7ca8d2ecac1f81d3c3944b01cb0ca32e73838659ec443a OR @permanentid=ddd67756dd4fe0de6cf866d8fab51fa01909840c9b39a9ef76955d5bdc94 OR @permanentid=a79ff2303b93e98d68ed051a1e90c03332f7b962d15f2ac4c1d47ef13fbc", + "duration": 100, + "extendedResults": {}, + "facets": [], + "groupByResults": [], + "index": "employmentandsocialdevelopmentcanadanonproduction14o5d9wry-9di7prgj-Indexer-2-q4pnjkhqqifygbugofmiod2b5a", + "indexDuration": 51, + "indexRegion": "ca-central-1", + "indexToken": "ZW1wbG95bWVudGFuZHNvY2lhbGRldmVsb3BtZW50Y2FuYWRhbm9ucHJvZHVjdGlvbjE0bzVkOXdyeS05ZGk3cHJnai1JbmRleGVyLTItcTRwbmpraHFxaWZ5Z2J1Z29mbWlvZDJiNWE=", + "largeExpression": null, + "logicalIndex": "default", + "mandatoryExpression": null, + "phrasesToHighlight": {}, + "pipeline": "Canada public websites - Generic", + "queryCorrections": [], + "questionAnswer": { + "answerFound": false, + "answerSnippet": "", + "documentId": { + "contentIdKey": "", + "contentIdValue": "" + }, + "question": "", + "relatedQuestions": [], + "score": 0 + }, + "rankingExpressions": [ + { + "applyToEveryResult": true, + "expression": "@generator==gcnews ((@declared_type=\"media advisories\" @date Benefits", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "05e65251c3fa3635a1f0d778dd52a84d16f13cdf7cbea1d5283ffdcecffd", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Employment and Social Development Canada" + ], + "syscollection": "default", + "sysdate": 1780617600000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "XKMFxwkOcw3dChmU", + "urihash": "XKMFxwkOcw3dChmU" + }, + "score": 7462, + "summary": null, + "summaryHighlights": [], + "title": "Disability benefits", + "titleHighlights": [], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/disability.html", + "uri": "https://www.canada.ca/en/services/benefits/disability.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "Excerpt": "Canada Disability Benefit Important: Filing your tax return is required ... The Canada Disability Benefit provides direct financial support to people with disabilities who are between 18 and 64 years ... Related links Disability Tax Credit Benefits for people with disabilities Organizations providing disability benefits navigation services", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "Title": "Canada Disability Benefit", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "Uri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "excerpt": "Canada Disability Benefit Important: Filing your tax return is required ... The Canada Disability Benefit provides direct financial support to people with disabilities who are between 18 and 64 years ... Related links Disability Tax Credit Benefits for people with disabilities Organizations providing disability benefits navigation services", + "excerptHighlights": [], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 92.63505, + "primaryid": "IVQVSWODWA3VGS22JNGGRQ5RGNRGELRSGAYDCLTEMVTGC5LMOQ", + "printableUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "printableUriHighlights": [ + { + "length": 84, + "offset": 0 + } + ], + "rankingInfo": null, + "rating": 0, + "raw": { + "author": [ + "Service Canada" + ], + "collection": "default", + "date": 1781827200000, + "description": "The Canada Disability Benefit is a monthly payment for working-age persons with disabilities who have low income", + "disp_declared_type": "service initiation", + "displaynavlabel": "www.canada.ca > Benefits > Disability benefits", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "9e54a36d38ac8cf2e6fdf9a525ce9a3ee27e1cdf385bc65b0bbb73d31863", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Service Canada" + ], + "syscollection": "default", + "sysdate": 1781827200000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "EaYYð7SKZKLhñ3bb", + "urihash": "EaYYð7SKZKLhñ3bb" + }, + "score": 7488, + "summary": null, + "summaryHighlights": [], + "title": "Canada Disability Benefit", + "titleHighlights": [], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "uri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "Excerpt": "Death benefit Death benefit The Canada Pension Plan (CPP) death benefit is a one-time payment, payable to the estate or other eligible individuals, on behalf of a deceased CPP contributor. ... complete the online CPP Death Benefit form ... the individual who receives it, refer to Death benefits - Prepare tax returns for someone who died.", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "Title": "Death Benefit", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "Uri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "excerpt": "Death benefit Death benefit The Canada Pension Plan (CPP) death benefit is a one-time payment, payable to the estate or other eligible individuals, on behalf of a deceased CPP contributor. ... complete the online CPP Death Benefit form ... the individual who receives it, refer to Death benefits - Prepare tax returns for someone who died.", + "excerptHighlights": [], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 91.56495, + "primaryid": "JU3VS2RXLBQWQWBWO44G4NLTPAXDEMBQGEXGIZLGMF2WY5A", + "printableUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "printableUriHighlights": [ + { + "length": 84, + "offset": 0 + } + ], + "rankingInfo": "Document weights:\nTitle: 184; Quality: 0; Date: 430; Adjacency: 1214; Source: 0; Custom: 0; QRE: 3000; Ranking functions: 0; \nQRE:\nExpression: \"@uri=veterans.gc.ca @uri=(remembrance/memorials/books/page,remembrance/memorials/canadian-virtual-war-memorial/detail)\" Score: 0\nExpression: \"@title=(\"Error 500\",\"Error 404\",archive,archivé,archivée)\" Score: 0\nExpression: \"@uri=canada.ca/en/news/archive/ OR @uri=canada.ca/fr/nouvelles/archive/ OR (@generator==gcnews ((@declared_type=\"news releases\" @date Canada Pension Plan retirement pension", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "846f42a8438a599f972379543a1b899d42ce71d8dc8ddeac5e3e21ac1350", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Employment and Social Development Canada" + ], + "syscollection": "default", + "sysdate": 1779667200000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "M7Yj7XahX6w8n5sx", + "urihash": "M7Yj7XahX6w8n5sx" + }, + "score": 7316, + "summary": null, + "summaryHighlights": [], + "title": "Death Benefit", + "titleHighlights": [], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "uri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "Excerpt": "Canada Disability Benefit Important: Filing your tax return is required ... The Canada Disability Benefit provides direct financial support to people with ... Disability Tax Credit Benefits for people with disabilities Organizations providing disability benefits navigation services ... About the Canada Disability Benefit program", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "Title": "Canada Disability Benefit", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "Uri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "excerpt": "Canada Disability Benefit Important: Filing your tax return is required ... The Canada Disability Benefit provides direct financial support to people with ... Disability Tax Credit Benefits for people with disabilities Organizations providing disability benefits navigation services ... About the Canada Disability Benefit program", + "excerptHighlights": [ + { + "length": 7, + "offset": 18 + }, + { + "length": 7, + "offset": 315 + }, + { + "length": 7, + "offset": 98 + }, + { + "length": 8, + "offset": 181 + }, + { + "length": 8, + "offset": 254 + } + ], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 99.31753, + "primaryid": "IVQVSWODWA3VGS22JNGGRQ5RGNRGELRSGAYDCLTEMVTGC5LMOQ", + "printableUri": "https://www.canada.ca/en/services/benefits/disability/canada-disability-benefit.html", + "printableUriHighlights": [ + { + "length": 7, + "offset": 72 + }, + { + "length": 8, + "offset": 34 + } + ], + "rankingInfo": "Document weights:\nTitle: 646; Quality: 0; Date: 614; Adjacency: 0; Source: 0; Custom: 0; QRE: 3000; Ranking functions: 0; \nQRE:\nExpression: \"@uri=veterans.gc.ca @uri=(remembrance/memorials/books/page,remembrance/memorials/canadian-virtual-war-memorial/detail)\" Score: 0\nExpression: \"@title=(\"Error 500\",\"Error 404\",archive,archivé,archivée)\" Score: 0\nExpression: \"@uri=canada.ca/en/news/archive/ OR @uri=canada.ca/fr/nouvelles/archive/ OR (@generator==gcnews ((@declared_type=\"news releases\" @date Benefits", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "43f4b25cb874cf7fb3f534fde77f94813f31953ee2fc3afaa79798df289d", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Employment and Social Development Canada" + ], + "syscollection": "default", + "sysdate": 1780444800000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "m8ðkEaJñxNsVBZkd", + "urihash": "m8ðkEaJñxNsVBZkd" + }, + "score": 7809, + "summary": null, + "summaryHighlights": [], + "title": "Employment Insurance benefits", + "titleHighlights": [ + { + "length": 8, + "offset": 21 + } + ], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/ei.html", + "uri": "https://www.canada.ca/en/services/benefits/ei.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "Excerpt": "The EI program offers 3 types of caregiving benefits: Types of caregiving benefits Benefit name Maximum weeks payable Family caregiver benefit for children up to 35 weeks Family caregiver benefit for adults up to 15 weeks Compassionate care benefits up to 26 weeks Family caregiver benefits for children If you're away from work to ...", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "Title": "EI caregiving benefits", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "Uri": "https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "excerpt": "The EI program offers 3 types of caregiving benefits: Types of caregiving benefits Benefit name Maximum weeks payable Family caregiver benefit for children up to 35 weeks Family caregiver benefit for adults up to 15 weeks Compassionate care benefits up to 26 weeks Family caregiver benefits for children If you're away from work to ...", + "excerptHighlights": [ + { + "length": 7, + "offset": 135 + }, + { + "length": 7, + "offset": 188 + }, + { + "length": 7, + "offset": 83 + }, + { + "length": 8, + "offset": 241 + }, + { + "length": 8, + "offset": 282 + }, + { + "length": 8, + "offset": 44 + }, + { + "length": 8, + "offset": 74 + } + ], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 98.337906, + "primaryid": "INMVUMCPIUZHQYSZNRYFIM2POMXDEMBQGEXGIZLGMF2WY5A", + "printableUri": "https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "printableUriHighlights": [ + { + "length": 8, + "offset": 34 + } + ], + "rankingInfo": "Document weights:\nTitle: 646; Quality: 0; Date: 462; Adjacency: 0; Source: 0; Custom: 0; QRE: 3000; Ranking functions: 0; \nQRE:\nExpression: \"@uri=veterans.gc.ca @uri=(remembrance/memorials/books/page,remembrance/memorials/canadian-virtual-war-memorial/detail)\" Score: 0\nExpression: \"@title=(\"Error 500\",\"Error 404\",archive,archivé,archivée)\" Score: 0\nExpression: \"@uri=canada.ca/en/news/archive/ OR @uri=canada.ca/fr/nouvelles/archive/ OR (@generator==gcnews ((@declared_type=\"news releases\" @date Employment Insurance benefits", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "e82e275da90ac399c8d4d0bd228d760f147623a9985035de34fb0650806e", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Employment and Social Development Canada" + ], + "syscollection": "default", + "sysdate": 1780358400000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "CYZ0OE2xbYlpT3Os", + "urihash": "CYZ0OE2xbYlpT3Os" + }, + "score": 7891, + "summary": null, + "summaryHighlights": [], + "title": "EI caregiving benefits", + "titleHighlights": [ + { + "length": 8, + "offset": 14 + } + ], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html", + "uri": "https://www.canada.ca/en/services/benefits/ei/ei-critically-ill-children/apply.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "Excerpt": "EI fishing benefits - Overview What these benefits offer Employment Insurance (EI) provides fishing benefits to qualifying, self-employed ... Unlike regular EI benefits, eligibility for EI fishing benefits is based on earnings, not insurable hours of ... may be eligible to receive regular fishing benefits as well as sickness, maternity, ...", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "Title": "EI Fishing benefits - Overview", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "Uri": "https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "excerpt": "EI fishing benefits - Overview What these benefits offer Employment Insurance (EI) provides fishing benefits to qualifying, self-employed ... Unlike regular EI benefits, eligibility for EI fishing benefits is based on earnings, not insurable hours of ... may be eligible to receive regular fishing benefits as well as sickness, maternity, ...", + "excerptHighlights": [ + { + "length": 8, + "offset": 100 + }, + { + "length": 8, + "offset": 11 + }, + { + "length": 8, + "offset": 160 + }, + { + "length": 8, + "offset": 197 + }, + { + "length": 8, + "offset": 298 + }, + { + "length": 8, + "offset": 42 + } + ], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 97.8131, + "primaryid": "N5HUW4CGJFHVAQJROFXXQVDWJIXDEMBQGEXGIZLGMF2WY5A", + "printableUri": "https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "printableUriHighlights": [ + { + "length": 8, + "offset": 34 + } + ], + "rankingInfo": "Document weights:\nTitle: 560; Quality: 0; Date: 462; Adjacency: 0; Source: 0; Custom: 0; QRE: 3000; Ranking functions: 0; \nQRE:\nExpression: \"@uri=veterans.gc.ca @uri=(remembrance/memorials/books/page,remembrance/memorials/canadian-virtual-war-memorial/detail)\" Score: 0\nExpression: \"@title=(\"Error 500\",\"Error 404\",archive,archivé,archivée)\" Score: 0\nExpression: \"@uri=canada.ca/en/news/archive/ OR @uri=canada.ca/fr/nouvelles/archive/ OR (@generator==gcnews ((@declared_type=\"news releases\" @date Employment Insurance benefits", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "0bdcf82331c5e443075d8d587061a2717b0372cb209ae41e450f3b38ec08", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Employment and Social Development Canada" + ], + "syscollection": "default", + "sysdate": 1780358400000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "oOKpFIOPA1qoxTvJ", + "urihash": "oOKpFIOPA1qoxTvJ" + }, + "score": 7807, + "summary": null, + "summaryHighlights": [], + "title": "EI Fishing benefits - Overview", + "titleHighlights": [ + { + "length": 8, + "offset": 11 + } + ], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/ei/ei-fishing.html", + "uri": "https://www.canada.ca/en/services/benefits/ei/ei-fishing.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "Excerpt": "Death benefit Death benefit The Canada Pension Plan (CPP) death benefit is a one-time payment, payable to the ... have never received a disability benefit, post-retirement disability benefit or retirement pension under the CPP or Quebec Pension Plan ( ... Survivor’s pension Benefits for children under 25 Step 2 How much could you receive", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "Title": "Death Benefit", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "Uri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "excerpt": "Death benefit Death benefit The Canada Pension Plan (CPP) death benefit is a one-time payment, payable to the ... have never received a disability benefit, post-retirement disability benefit or retirement pension under the CPP or Quebec Pension Plan ( ... Survivor’s pension Benefits for children under 25 Step 2 How much could you receive", + "excerptHighlights": [ + { + "length": 7, + "offset": 147 + }, + { + "length": 7, + "offset": 183 + }, + { + "length": 7, + "offset": 20 + }, + { + "length": 7, + "offset": 64 + }, + { + "length": 7, + "offset": 6 + }, + { + "length": 8, + "offset": 275 + } + ], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 99.00857, + "primaryid": "JU3VS2RXLBQWQWBWO44G4NLTPAXDEMBQGEXGIZLGMF2WY5A", + "printableUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "printableUriHighlights": [ + { + "length": 7, + "offset": 72 + }, + { + "length": 8, + "offset": 34 + } + ], + "rankingInfo": "Document weights:\nTitle: 791; Quality: 0; Date: 430; Adjacency: 0; Source: 0; Custom: 0; QRE: 3000; Ranking functions: 0; \nQRE:\nExpression: \"@uri=veterans.gc.ca @uri=(remembrance/memorials/books/page,remembrance/memorials/canadian-virtual-war-memorial/detail)\" Score: 0\nExpression: \"@title=(\"Error 500\",\"Error 404\",archive,archivé,archivée)\" Score: 0\nExpression: \"@uri=canada.ca/en/news/archive/ OR @uri=canada.ca/fr/nouvelles/archive/ OR (@generator==gcnews ((@declared_type=\"news releases\" @date Canada Pension Plan retirement pension", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "846f42a8438a599f972379543a1b899d42ce71d8dc8ddeac5e3e21ac1350", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Employment and Social Development Canada" + ], + "syscollection": "default", + "sysdate": 1779667200000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "M7Yj7XahX6w8n5sx", + "urihash": "M7Yj7XahX6w8n5sx" + }, + "score": 7999, + "summary": null, + "summaryHighlights": [], + "title": "Death Benefit", + "titleHighlights": [ + { + "length": 7, + "offset": 6 + } + ], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html", + "uri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/cpp-death-benefit.html" + }, + { + "ClickUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "Excerpt": "Receive your benefits On this page ... To cancel your benefit, contact Service Canada. ... Death benefit: A one-time payment on behalf of the deceased contributor ... CPP children's benefit: A monthly benefit for a dependent child of a deceased CPP ... general questions about CPP retirement pension benefits or specific questions about your ...", + "FirstSentences": null, + "PrintableUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "Title": "Receive your benefits", + "UniqueId": "42.2001$https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "Uri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "absentTerms": [], + "childResults": [], + "clickUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "excerpt": "Receive your benefits On this page ... To cancel your benefit, contact Service Canada. ... Death benefit: A one-time payment on behalf of the deceased contributor ... CPP children's benefit: A monthly benefit for a dependent child of a deceased CPP ... general questions about CPP retirement pension benefits or specific questions about your ...", + "excerptHighlights": [ + { + "length": 7, + "offset": 182 + }, + { + "length": 7, + "offset": 201 + }, + { + "length": 7, + "offset": 54 + }, + { + "length": 7, + "offset": 97 + }, + { + "length": 8, + "offset": 13 + }, + { + "length": 8, + "offset": 300 + } + ], + "firstSentences": null, + "firstSentencesHighlights": [], + "flags": "HasHtmlVersion;HasAllMetaDataStream", + "hasHtmlVersion": true, + "hasMobileHtmlVersion": false, + "isRecommendation": false, + "isTopResult": false, + "isUserActionView": false, + "parentResult": null, + "percentScore": 98.44377, + "primaryid": "OBEUQODTKVKVENRWKJXWUSLQMQXDEMBQGEXGIZLGMF2WY5A", + "printableUri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "printableUriHighlights": [ + { + "length": 8, + "offset": 34 + }, + { + "length": 8, + "offset": 70 + } + ], + "rankingInfo": "Document weights:\nTitle: 646; Quality: 0; Date: 593; Adjacency: 0; Source: 0; Custom: 0; QRE: 3000; Ranking functions: 0; \nQRE:\nExpression: \"@uri=veterans.gc.ca @uri=(remembrance/memorials/books/page,remembrance/memorials/canadian-virtual-war-memorial/detail)\" Score: 0\nExpression: \"@title=(\"Error 500\",\"Error 404\",archive,archivé,archivée)\" Score: 0\nExpression: \"@uri=canada.ca/en/news/archive/ OR @uri=canada.ca/fr/nouvelles/archive/ OR (@generator==gcnews ((@declared_type=\"news releases\" @date Canada Pension Plan retirement pension", + "hostname": "canada.ca", + "language": [ + "English" + ], + "permanentid": "9472b4909e0543e71984a0fa7647560485e4e9596b0109cbfb5defd4d4a0", + "source": "priority_canada_ca_sitemap", + "sourcetype": "Sitemap", + "sysauthor": [ + "Service Canada" + ], + "syscollection": "default", + "sysdate": 1781740800000, + "syslanguage": [ + "English" + ], + "syssource": "priority_canada_ca_sitemap", + "syssourcetype": "Sitemap", + "sysurihash": "pIH8sUUR66RojIpd", + "urihash": "pIH8sUUR66RojIpd" + }, + "score": 7908, + "summary": null, + "summaryHighlights": [], + "title": "Receive your benefits", + "titleHighlights": [ + { + "length": 8, + "offset": 13 + } + ], + "totalNumberOfChildResults": 0, + "uniqueId": "42.2001$https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html", + "uri": "https://www.canada.ca/en/services/benefits/publicpensions/cpp/receive-benefits.html" + } + ], + "searchUid": "ac01518d-3264-4aba-8b24-815a6f3e0a7f", + "splitTestRun": "Canada public websites - Gene...-mirror-1733855881", + "suggestedFacets": [], + "termsToHighlight": { + "benefits": [ + "benefit", + "benefited", + "benefitically", + "benefiting", + "benefitted", + "benefitting", + "benefıts", + "bénefit", + "bénéfition", + "bénéfits" + ] + }, + "topResults": [ + { + "applyToEveryResult": true, + "exclusive": false, + "expression": "@permanentid=8474a65cdb75baf0af880228aaae5e8a374fff804e0d571fc41dce1734bb", + "includeInFacets": true, + "isConstant": false, + "matchAdvancedQuery": true, + "matchQuery": false, + "modifier": 250 + }, + { + "applyToEveryResult": true, + "exclusive": false, + "expression": "@permanentid=a79ff2303b93e98d68ed051a1e90c03332f7b962d15f2ac4c1d47ef13fbc", + "includeInFacets": true, + "isConstant": false, + "matchAdvancedQuery": true, + "matchQuery": false, + "modifier": 159 + }, + { + "applyToEveryResult": true, + "exclusive": false, + "expression": "@permanentid=ddd67756dd4fe0de6cf866d8fab51fa01909840c9b39a9ef76955d5bdc94", + "includeInFacets": true, + "isConstant": false, + "matchAdvancedQuery": true, + "matchQuery": false, + "modifier": 250 + }, + { + "applyToEveryResult": true, + "exclusive": false, + "expression": "@permanentid=e0002bdd6d3c6f7ca8d2ecac1f81d3c3944b01cb0ca32e73838659ec443a", + "includeInFacets": true, + "isConstant": false, + "matchAdvancedQuery": true, + "matchQuery": false, + "modifier": 250 + } + ], + "totalCount": 193654, + "totalCountFiltered": 193653, + "triggers": [], + "userIdentities": [ + { + "name": "anonymous_user@anonymous.coveo.com", + "provider": "Email Security Provider", + "type": "User" + } + ] +} \ No newline at end of file diff --git a/e2e/no-qs-en.spec.js b/e2e/no-qs-en.spec.js new file mode 100644 index 0000000..fd04e47 --- /dev/null +++ b/e2e/no-qs-en.spec.js @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; + +// no-qs-en.html sets numberOfSuggestions to 0 — query suggestions are disabled entirely, so the +// combobox/listbox wiring connector.js otherwise applies to the search box is never added. +test.describe('no-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/no-qs-en.html'); + }); + + test('search box is initialized without any query suggestion UI', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + + // Check that the search box is present and has the expected attributes for a plain search input, without + // any of the ARIA attributes or roles that would be added if query suggestions were enabled. + await expect(searchBox).toHaveAttribute('type', 'search'); + await expect(searchBox).not.toHaveAttribute('role', 'combobox'); + await expect(searchBox).not.toHaveAttribute('aria-expanded'); + await expect(searchBox).not.toHaveAttribute('aria-autocomplete'); + await expect(searchBox).not.toHaveAttribute('aria-controls'); + + await expect(page.locator('form[role="search"] ul#suggestions')).toHaveCount(0); + await expect(page.locator('form[role="search"] p#sr-qs-hint')).toHaveCount(0); + }); + + test('typing a query does not show suggestions or request the querySuggest endpoint', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + + // Listen for any requests to the querySuggest endpoint. If any are made, flip `suggestRequestSeen` to true + let suggestRequestSeen = false; + page.on('request', req => { + if (req.url().includes('/querySuggest')) suggestRequestSeen = true; + }); + + // Type a query into the search box. Normally this would trigger query suggestions. + await searchBox.focus(); + await page.keyboard.type('canada'); + await page.waitForTimeout(1000); // Give any suggestion request a chance to fire/settle; none should be made. + + // Check that no querySuggest request was made, and that there are no signs of query suggestions in the UI + expect(suggestRequestSeen).toBe(false); + await expect(searchBox).not.toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#suggestions')).toHaveCount(0); + }); + + test('returning focus to the search box after a search does not show suggestions', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + + // Set up the initial conditions. Type a query and submit the form to perform a search. + await searchBox.focus(); + await page.keyboard.type('canada'); + await page.keyboard.press('Enter'); + await page.waitForLoadState('networkidle'); + + // Listen for any requests to the querySuggest endpoint. If any are made, flip `suggestRequestSeen` to true + let suggestRequestSeen = false; + page.on('request', req => { + if (req.url().includes('/querySuggest')) suggestRequestSeen = true; + }); + + // Return focus to the search box. + await searchBox.focus(); + await page.waitForTimeout(1000); + + // Check that no querySuggest request was made, and that there are no signs of query suggestions in the UI + expect(suggestRequestSeen).toBe(false); + await expect(searchBox).not.toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#suggestions')).toHaveCount(0); + }); +}); diff --git a/e2e/qs-en-topright.spec.js b/e2e/qs-en-topright.spec.js new file mode 100644 index 0000000..db37785 --- /dev/null +++ b/e2e/qs-en-topright.spec.js @@ -0,0 +1,289 @@ +import { test, expect } from '@playwright/test'; + +// TODO: A separate branch switches the app's typing/suggestion handling from keydown/keyup +// listeners to generic `input` events. Once that lands here, revisit the `page.keyboard.type()` / +// `page.keyboard.press()` usage throughout this file — some of it may need to change to +// `locator.fill()` / dispatched `input` events (or may still need real key events for +// Tab/Shift+Tab/Arrow/Enter navigation) to keep exercising the actual code path. +// +// Unlike qs-en.spec.js (which uses src/connector.js and renders results in-page via a Coveo +// headless engine), these pages use src/suggestions.js against the WET-BOEW top-right search box +// (#wb-srch-q). Selecting a suggestion or submitting the form navigates the whole page to a real +// https://www.canada.ca/en/sr/srb.html URL, so requests to that host are stubbed below to keep +// tests hermetic and avoid hitting production. + +const pages = [ + { + name: 'default config (5 suggestions, 3 char minimum)', + url: 'http://localhost:4000/tests/qs-en-topright.html', + numberOfSuggestions: 5, + minimumCharsForSuggestions: 3, + }, + { + name: 'custom config (15 suggestions, 1 char minimum)', + url: 'http://localhost:4000/tests/qs-en-topright-custom.html', + numberOfSuggestions: 15, + minimumCharsForSuggestions: 1, + }, +]; + +for (const { name, url, numberOfSuggestions, minimumCharsForSuggestions } of pages) { + test.describe(`QS EN top-right search box — ${name}`, () => { + 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 }); + }); + + // Stub navigation to the real canada.ca search results page so selecting/submitting a + // suggestion doesn't leave localhost or hit production. + await page.route('https://www.canada.ca/**', route => + route.fulfill({ status: 200, contentType: 'text/html', body: 'stubbed' }) + ); + + await page.goto(url); + }); + + test('query suggestion UI is initialized', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + 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('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('p#sr-qs-hint'); + await expect(hint).toHaveClass(/hidden/); + }); + + test(`suggestions appear once the ${minimumCharsForSuggestions}-character minimum is reached and disappear below it`, async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + await searchBox.focus(); + + // Below the configured minimum, the box closes synchronously on input — no /querySuggest + // request is fired, so only wait on the network once we're at/above the threshold. Waiting + // unconditionally on a request that never arrives just burns the full 5s timeout per + // keystroke and was making this test flake on slower CI runs. + const word = 'canada'; + for (const [i, char] of [...word].entries()) { + const typedSoFar = word.slice(0, i + 1); + + if (typedSoFar.length < minimumCharsForSuggestions) { + await page.keyboard.type(char); + await expect(searchBox).toHaveAttribute('aria-expanded', 'false'); + await expect(suggestionsList).toHaveAttribute('hidden'); + } else { + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type(char); + await responsePromise; + + 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).toBeGreaterThanOrEqual(1); + expect(count).toBeLessThanOrEqual(numberOfSuggestions); + } + } + + // Backspace back down; suggestions should stay visible until below the configured minimum. + for (let remaining = word.length - 1; remaining >= 0; remaining--) { + if (remaining >= minimumCharsForSuggestions) { + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.press('Backspace'); + await responsePromise; + + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + } else { + await page.keyboard.press('Backspace'); + await expect(searchBox).toHaveAttribute('aria-expanded', 'false'); + await expect(suggestionsList).toHaveAttribute('hidden'); + } + } + }); + + test('at most the configured number of suggestions are shown', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + await searchBox.focus(); + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('canada'); + await responsePromise; + // Let all in-flight /querySuggest requests (one per keystroke) settle before counting. + await page.waitForLoadState('networkidle'); + + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionItems.first()).toBeVisible(); + const count = await suggestionItems.count(); + expect(count).toBeGreaterThanOrEqual(1); + expect(count).toBeLessThanOrEqual(numberOfSuggestions); + }); + + test('clicking a query suggestion navigates to the search results page for that suggestion', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + await searchBox.focus(); + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('canada'); + await responsePromise; + + // Typing "canada" fires one /querySuggest request per keystroke (more so with a low + // minimumCharsForSuggestions). Wait for them all to settle so the list isn't re-rendered + // out from under the click between reading the suggestion text and clicking it. + await page.waitForLoadState('networkidle'); + + await expect(suggestionItems.first()).toBeVisible(); + const secondSuggestion = suggestionItems.nth(1); + const suggestionText = await secondSuggestion.innerText(); + + await Promise.all([ + page.waitForURL(/canada\.ca\/en\/sr\/srb\.html\?/, { timeout: 5000 }), + secondSuggestion.click(), + ]); + + const requestUrl = new URL(page.url()); + expect(requestUrl.searchParams.get('q')).toBe(suggestionText); + expect(requestUrl.searchParams.get('actionCause')).toBe('omniboxFromLink'); + }); + + test('query suggestions can be navigated by keyboard and selected with Enter', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + await searchBox.focus(); + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('canada'); + await responsePromise; + await expect(suggestionItems.first()).toBeVisible(); + + // Typing "canada" fires one /querySuggest request per keystroke (more so with a low + // minimumCharsForSuggestions). Wait for them all to settle so a late response can't reopen + // the suggestions box out from under the assertions below — its render callback has no + // focus check, so a stale response arriving after Tab would otherwise re-show the list. + await page.waitForLoadState('networkidle'); + + // Tab out closes the suggestions; shift+tab back in reopens them. + await page.keyboard.press('Tab'); + await expect(suggestionsList).toHaveAttribute('hidden'); + await page.keyboard.press('Shift+Tab'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + + const count = await suggestionItems.count(); + const originalValue = await searchBox.inputValue(); + + // Down through the whole list lands on the last item; one more wraps to the first. + for (let i = 0; i < count; i++) { + await page.keyboard.press('ArrowDown'); + } + await expect(suggestionItems.last()).toHaveClass(/selected-suggestion/); + await page.keyboard.press('ArrowDown'); + await expect(suggestionItems.first()).toHaveClass(/selected-suggestion/); + await expect(searchBox).toHaveValue(originalValue); + await expect(searchBox).toBeFocused(); + + // Up from the first item wraps to the last. + await page.keyboard.press('ArrowUp'); + await expect(suggestionItems.last()).toHaveClass(/selected-suggestion/); + + // Select a suggestion other than the current query text (the second item) via Enter. + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + const activeText = await suggestionItems.nth(1).innerText(); + + await Promise.all([ + page.waitForURL(/canada\.ca\/en\/sr\/srb\.html\?/, { timeout: 5000 }), + page.keyboard.press('Enter'), + ]); + + const requestUrl = new URL(page.url()); + expect(requestUrl.searchParams.get('q')).toBe(activeText); + }); + }); +} + +test.describe('Fixes, patches, and improvements', () => { + 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 }); + }); + + // Stub navigation to the real canada.ca search results page so selecting/submitting a + // suggestion doesn't leave localhost or hit production. + await page.route('https://www.canada.ca/**', route => + route.fulfill({ status: 200, contentType: 'text/html', body: 'stubbed' }) + ); + + await page.goto('http://localhost:4000/tests/qs-en-topright.html'); + }); + + // SR-625 Top-Right QS: Small Updates + test('SR-625 TR-01: suggestions list switches to position: static at the 767px mobile breakpoint', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + const suggestionsList = page.locator('#suggestions'); + + await searchBox.focus(); + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('canada'); + await responsePromise; + await expect(suggestionsList).not.toHaveAttribute('hidden'); + + // Just above the breakpoint — still absolutely positioned. + await page.setViewportSize({ width: 768, height: 800 }); + await expect(suggestionsList).toHaveCSS('position', 'absolute'); + + // At/below the 767px breakpoint — static. + await page.setViewportSize({ width: 767, height: 800 }); + await expect(suggestionsList).toHaveCSS('position', 'static'); + }); + + test('SR-625 TR-02: the "list" attribute is removed from the header search box input', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + await expect(searchBox).not.toHaveAttribute('list'); + }); + + // PR 63 - adjust actionCause + test('PR #63: submitting via Enter (without selecting a suggestion) forwards with actionCause=searchFromLink', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + + await searchBox.focus(); + await page.keyboard.type('canada'); + + await Promise.all([ + page.waitForURL(/canada\.ca\/en\/sr\/srb\.html\?/, { timeout: 5000 }), + page.keyboard.press('Enter'), + ]); + + const requestUrl = new URL(page.url()); + expect(requestUrl.searchParams.get('actionCause')).toBe('searchFromLink'); + }); + + test('PR #63: submitting via the search button forwards with actionCause=searchFromLink', async ({ page }) => { + const searchBox = page.locator('#wb-srch-q'); + + await searchBox.focus(); + await page.keyboard.type('canada'); + + await Promise.all([ + page.waitForURL(/canada\.ca\/en\/sr\/srb\.html\?/, { timeout: 5000 }), + page.locator('#wb-srch-sub').click(), + ]); + + const requestUrl = new URL(page.url()); + expect(requestUrl.searchParams.get('actionCause')).toBe('searchFromLink'); + }); +}); diff --git a/e2e/qs-en.spec.js b/e2e/qs-en.spec.js new file mode 100644 index 0000000..21ab478 --- /dev/null +++ b/e2e/qs-en.spec.js @@ -0,0 +1,290 @@ +import { test, expect } from '@playwright/test'; + +// TODO: A separate branch switches the app's typing/suggestion handling from keydown/keyup +// listeners to generic `input` events. These tests may need to be revised once those changes +// are merged in. + +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'); + + // Check that the search box is present and has the expected attributes and ARIA attributes/roles for query suggestions. + 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'); + + // Move the focus to the search box so that typing will trigger query suggestions. + 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) { + + // If 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--) { + + // 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.press('Backspace'); + await responsePromise; + + if (remaining >= 3) { + + // If greater than 3 characters, suggestions should be shown. + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + await expect(suggestionItems.first()).toBeVisible(); + + } else { + + // For 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'); + + // Move the focus to the search box so that typing will trigger query suggestions. + 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; + + // Wait for them all network requests to settle so the list isn't re-rendered out from under the click + await page.waitForLoadState('networkidle'); + + // Check that the suggestions list is visible and has at least one item + await expect(suggestionItems.first()).toBeVisible(); + + // Save the text from the second suggestion in the list (if it exists), then click it + 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); + }); + + test('query suggestions reappear when focus returns to the search box after a search', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + const suggestionsList = page.locator('#suggestions'); + + // Perform a search for "canada" and load the results. + await searchBox.focus(); + await searchBox.fill('canada'); + await searchBox.press('Enter'); + await page.waitForLoadState('networkidle'); + + // The suggestions box should not be visible right after navigation/search. + await expect(suggestionsList).toHaveAttribute('hidden'); + + // Return focus to the search box by clicking into it and wait for the querySuggest request to complete. + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }).catch(() => null); + await searchBox.click(); + await responsePromise; + + // Query suggestions should reappear + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + }); + + test('query suggestions can be navigated and selected by keyboard', async ({ page }) => { + const searchBox = page.locator('#sch-inp-ac'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + // Move the focus to the search box and clear it + await searchBox.focus(); + await searchBox.fill(''); + + // Type "canada" one character at a time; suggestions should appear once 3 characters are typed (default minimum). + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('canada'); + await responsePromise; + + // Let all in-flight /querySuggest requests settle before interacting + await page.waitForLoadState('networkidle'); + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionItems.first()).toBeVisible(); + + // Tab out to the search icon — suggestions should disappear. + await page.keyboard.press('Tab'); + await expect(suggestionsList).toHaveAttribute('hidden'); + + // Shift+tab back into the search input — suggestions should reappear. + await page.keyboard.press('Shift+Tab'); + await expect(suggestionsList).not.toHaveAttribute('hidden'); + await expect(suggestionItems.first()).toBeVisible(); + + const count = await suggestionItems.count(); + const originalValue = await searchBox.inputValue(); + + // Arrow down through the whole list. This lands on the last item. + for (let i = 0; i < count; i++) { + await page.keyboard.press('ArrowDown'); + } + + // Check that the last item is selected and has the expected ARIA attributes. + await expect(suggestionItems.last()).toHaveClass(/selected-suggestion/); + await expect(suggestionItems.last()).toHaveAttribute('aria-selected', 'true'); + + // One more down press should wrap around back to the first item. + await page.keyboard.press('ArrowDown'); + + // Check that the first item is selected and has the expected ARIA attributes. + await expect(suggestionItems.first()).toHaveClass(/selected-suggestion/); + await expect(suggestionItems.first()).toHaveAttribute('aria-selected', 'true'); + + // The input value should not have changed while navigating. + await expect(searchBox).toHaveValue(originalValue); + + // The input should retain focus while navigating the list. + await expect(searchBox).toBeFocused(); + + // Arrow up from the first item should wrap backwards to the last item. + await page.keyboard.press('ArrowUp'); + + // Check that the last item is selected, has the expected ARIA attributes, and the search box still has the original value and focus. + await expect(suggestionItems.last()).toHaveClass(/selected-suggestion/); + await expect(suggestionItems.last()).toHaveAttribute('aria-selected', 'true'); + await expect(searchBox).toHaveValue(originalValue); + await expect(searchBox).toBeFocused(); + + // Typing while an item is highlighted should update the query. + const typeResponsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.type('x'); + await expect(searchBox).toHaveValue(originalValue + 'x'); + await typeResponsePromise; + + // Backspacing should update the query and trigger a new /querySuggest request. + const backspaceResponsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + await page.keyboard.press('Backspace'); + await expect(searchBox).toHaveValue(originalValue); + await backspaceResponsePromise; + + // Move to a suggestion to the second item + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + // Check that the second item is selected and has the expected ARIA attributes. + await expect(suggestionsList).not.toHaveAttribute('hidden'); + const activeItem = suggestionItems.nth(1); + const activeText = await activeItem.innerText(); + + // Press Enter to submit a search for the selected suggestion. + await page.keyboard.press('Enter'); + + // The suggestions box should close immediately after submitting the search. + await expect(suggestionsList).toHaveAttribute('hidden'); + await expect(searchBox).toHaveValue(activeText); + + // Wait for the /querySuggest request to settle + await page.waitForLoadState('networkidle'); + + // The search summary should have the focus and contain the text of the suggestion we clicked on + const summary = page.locator('#wb-land h2'); + await expect(summary).toBeVisible(); + await expect(summary).toContainText(activeText); + }); +}); + +test.describe('QS EN page — configuration options', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + }); + + test('up to 10 suggestions are shown when numberOfSuggestions is set to 10', async ({ page }) => { + await page.goto('http://localhost:4000/tests/qs-en.html'); + + const searchBox = page.locator('#sch-inp-ac'); + const suggestionsList = page.locator('#suggestions'); + const suggestionItems = suggestionsList.locator('li.suggestion-item'); + + // Move the focus to the search box so that typing will trigger query suggestions. + await searchBox.focus(); + const responsePromise = page.waitForResponse(res => res.url().includes('/querySuggest'), { timeout: 5000 }); + + // Type "canada" and wait for all network requests to settle + await page.keyboard.type('canada'); + await responsePromise; + await page.waitForLoadState('networkidle'); + + // Check that the suggestions list is visible and has between 1 and 10 items + await expect(searchBox).toHaveAttribute('aria-expanded', 'true'); + await expect(suggestionItems.first()).toBeVisible(); + const count = await suggestionItems.count(); + expect(count).toBeGreaterThanOrEqual(1); + expect(count).toBeLessThanOrEqual(10); + }); + +}); diff --git a/e2e/sra-en.spec.js b/e2e/sra-en.spec.js new file mode 100644 index 0000000..4b7716d --- /dev/null +++ b/e2e/sra-en.spec.js @@ -0,0 +1,183 @@ +import { test, expect } from '@playwright/test'; + +// Description: Rather than asserting on rendered result content (which depends on live index data +// and can drift), these tests assert on the two things the app directly controls: the URL query +// string after submit, and the `q`/`aq` fields in the outgoing search request payload. +test.describe('SRA EN page — advanced search', () => { + 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/sra-en.html'); + }); + + // Helper: Fill the given advanced search fields, submit the form, and returns the relevants of + // the search request payload (`q`, `aq`) + async function submitAdvancedSearch(page, fields) { + for (const [selector, value] of Object.entries(fields)) { + const field = page.locator(selector); + const tagName = await field.evaluate(el => el.tagName); + + if (tagName === 'SELECT') { + await field.selectOption(value); + } else { + await field.fill(value); + } + } + + const [searchRequest] = await Promise.all([ + page.waitForRequest(req => { + if (!req.url().includes('/rest/search') || req.method() !== 'POST') return false; + try { + return JSON.parse(req.postData()).numberOfResults !== undefined; + } catch { + return false; + } + }), + page.locator('button[type="submit"]').click(), + ]); + + return JSON.parse(searchRequest.postData()); + } + + test.describe('Find pages with...', () => { + test('all of these words populates allq in the URL and an unquoted q in the request', async ({ page }) => { + + // Perform a search by populating the "all of these words" field and submitting the form. + const payload = await submitAdvancedSearch(page, { '#advseacon1': 'mark carney' }); + + // Check that the URL query string contains the expected `allq` parameter and that the request payload contains + // the expected `q` value. + const url = new URL(page.url()); + expect(url.searchParams.get('allq')).toBe('mark carney'); + expect(payload.q.trim()).toBe('mark carney'); + expect(payload.aq).toBeUndefined(); + }); + + test('this exact word or phrase populates exctq in the URL and a quoted q in the request', async ({ page }) => { + + // Perform a search by populating the "this exact word or phrase" field and submitting the form. + const payload = await submitAdvancedSearch(page, { '#advseacon2': 'mark carney' }); + + // Check that the URL query string contains the expected `exctq` parameter and that the request payload contains + // the expected quoted `q` value. + const url = new URL(page.url()); + expect(url.searchParams.get('exctq')).toBe('mark carney'); + expect(payload.q.trim()).toBe('("mark carney")'); + expect(payload.aq).toBeUndefined(); + }); + + test('any of these words populates anyq in the URL and an OR-joined q in the request', async ({ page }) => { + + // Perform a search by populating the "any of these words" field and submitting the form. + const payload = await submitAdvancedSearch(page, { '#advseacon3': 'mark carney' }); + + // Check that the URL query string contains the expected `anyq` parameter and that the request payload contains + // the expected OR-joined `q` value. + const url = new URL(page.url()); + expect(url.searchParams.get('anyq')).toBe('mark carney'); + expect(payload.q.trim()).toBe('(mark OR carney)'); + expect(payload.aq).toBeUndefined(); + }); + + test('none of these words populates noneq in the URL and a NOT-joined q in the request', async ({ page }) => { + + // Perform a search by populating the "none of these words" field and submitting the form. + const payload = await submitAdvancedSearch(page, { '#advseacon4': 'mark carney' }); + + // Check that the URL query string contains the expected `noneq` parameter and that the request payload contains + // the expected NOT-joined `q` value. + const url = new URL(page.url()); + expect(url.searchParams.get('noneq')).toBe('mark carney'); + expect(payload.q.trim()).toBe('(NOT (mark) NOT(carney))'); + expect(payload.aq).toBeUndefined(); + }); + }); + + test.describe('Find pages with… combinations', () => { + test('any of these words + none of these words combine into one q expression', async ({ page }) => { + + // Perform a search by populating the "any of these words" and "none of these words" fields and submitting the form. + const payload = await submitAdvancedSearch(page, { + '#advseacon3': 'mark carney', + '#advseacon4': 'prime minister', + }); + + // Check that the URL query string contains the expected `anyq` and `noneq` parameters and that the request payload + // contains the expected combined `q` value. + const url = new URL(page.url()); + expect(url.searchParams.get('anyq')).toBe('mark carney'); + expect(url.searchParams.get('noneq')).toBe('prime minister'); + expect(payload.q.trim()).toBe('(mark OR carney)(NOT (prime) NOT(minister))'); + }); + + test('exact word or phrase + none of these words combine into one q expression', async ({ page }) => { + + // Perform a search by populating the "exact word or phrase" and "none of these words" fields and submitting the form. + const payload = await submitAdvancedSearch(page, { + '#advseacon2': 'mark carney', + '#advseacon4': 'prime minister', + }); + + // Check that the URL query string contains the expected `exctq` and `noneq` parameters and that the request payload + const url = new URL(page.url()); + expect(url.searchParams.get('exctq')).toBe('mark carney'); + expect(url.searchParams.get('noneq')).toBe('prime minister'); + expect(payload.q.trim()).toBe('("mark carney")(NOT (prime) NOT(minister))'); + }); + }); + + test.describe('Find pages with… + Then narrow your results by...', () => { + test('exact word or phrase + pages updated adds a relative @date filter to aq', async ({ page }) => { + + // Perform a search by populating the "exact word or phrase" and "pages updated" fields and submitting the form. + const payload = await submitAdvancedSearch(page, { + '#advseacon2': 'mark carney', + '#advseacon5': 'dateModified_dt:[NOW-7DAYS TO NOW]', + }); + + // Check that the URL query string contains the expected `exctq` and `fqupdate` parameters and that the request payload + const url = new URL(page.url()); + expect(url.searchParams.get('exctq')).toBe('mark carney'); + expect(url.searchParams.get('fqupdate')).toBe('dateModified_dt:[NOW-7DAYS TO NOW]'); + expect(payload.q.trim()).toBe('("mark carney")'); + expect(payload.aq.trim()).toBe('@date>today-7d'); + }); + + test('exact word or phrase + site or domain adds an @uri filter to aq', async ({ page }) => { + + // Perform a search by populating the "exact word or phrase" and "site or domain" fields and submitting the form. + const payload = await submitAdvancedSearch(page, { + '#advseacon2': 'mark carney', + '#advseacon7': 'canada.ca', + }); + + // Check that the URL query string contains the expected `exctq` and `dmn` parameters and that the request payload + const url = new URL(page.url()); + expect(url.searchParams.get('exctq')).toBe('mark carney'); + expect(url.searchParams.get('dmn')).toBe('canada.ca'); + expect(payload.q.trim()).toBe('("mark carney")'); + expect(payload.aq.trim()).toBe('@uri="canada.ca"'); + }); + + test('exact word or phrase + terms appearing moves the query into an @title filter on aq', async ({ page }) => { + + // Perform a search by populating the "exact word or phrase" and "terms appearing" fields and submitting the form. + const payload = await submitAdvancedSearch(page, { + '#advseacon2': 'esdc', + '#advseacon8': 'title_t', + }); + + // Check that the URL query string contains the expected `exctq` and `fqocct` parameters and that the request payload + const url = new URL(page.url()); + expect(url.searchParams.get('exctq')).toBe('esdc'); + expect(url.searchParams.get('fqocct')).toBe('title_t'); + + // The phrase moves entirely into the aq @title filter — q is left empty. + expect(payload.q.trim()).toBe(''); + expect(payload.aq.trim()).toBe('@title= ("esdc")'); + }); + }); +}); diff --git a/e2e/srb-en.result-templates.spec.js b/e2e/srb-en.result-templates.spec.js new file mode 100644 index 0000000..10a9424 --- /dev/null +++ b/e2e/srb-en.result-templates.spec.js @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; +import searchFixture from './fixtures/result-template-results.json' assert { type: 'json' }; + +test.describe('Result templates', () => { + 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 }); + }); + + // Intercept the Coveo search API request and return a "mocked" response (fixture) so we can reliably + // perform tests over a range of result types, without depending on a a query specific query + set of results. + await page.route('**/rest/search**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(searchFixture), + }); + }); + + await page.goto('http://localhost:4000/tests/srb-en.html#q=benefits'); + }); + + test('result template renders core fields (title, link, author, breadcrumb, time, and excerpt)', async ({ page }) => { + + // Get first result in the list and the first item in the fixture to compare against. + const firstResult = page.locator('#wb-land #result-list section').first(); + const firstResultData = searchFixture.results[0]; + + // Ensure the result is visible. + await expect(firstResult).toBeVisible(); + + // Title links to the result's clickUri. + const titleLink = firstResult.locator('a.result-link'); + await expect(titleLink).toHaveText(firstResultData.title); + await expect(titleLink).toHaveAttribute('href', firstResultData.clickUri); + + // Author label comes from raw.author. + const author = Array.isArray(firstResultData.raw.author) + ? firstResultData.raw.author[0] + : firstResultData.raw.author; + await expect(firstResult.locator('ul.context-labels li')).toHaveText(author); + + // Excerpt is present and non-empty. + await expect(firstResult.locator('p')).toContainText(firstResultData.excerpt.slice(0, 30)); + + // Breadcrumb container is present. + await expect(firstResult.locator('.location')).toBeAttached(); + + // Date is rendered in a