diff --git a/documentation/prioritized-test-plan.md b/documentation/prioritized-test-plan.md new file mode 100644 index 0000000..b3f6012 --- /dev/null +++ b/documentation/prioritized-test-plan.md @@ -0,0 +1,121 @@ +# Prioritized Test Plan for `Universal-Federated-Analytics.js` + +## Priority 1 + +These cover the highest-value public behavior with the least setup risk. + +1. `gas()` legacy API + - `gas('send','pageview', ...)` emits `page_view` + - `gas('send','pageview', ..., title)` uses explicit title + - `gas('send','event', ...)` emits `dap_event` + - `gas('send','event', ...)` defaults `event_value` to `0` when missing or invalid + - `gas('send','event', ..., nonInteraction=true)` maps `non_interaction` + - invalid `gas()` calls do not emit unexpected events + +2. `gas4()` core helper coverage + - `gas4('page_view', {...})` emits `page_view` + - unsupported `gas4()` name falls back to `dap_event` + - supported event with empty params object still emits event + - `gas4()` with malformed args does not emit + - `gas4('view_search_results', ...)` explicit call emits expected event + +3. Autotracker link classifications + - internal `mailto:` emits `email_click` + - external `mailto:` emits `email_click` + - formatted telephone link emits `telephone_click` + - invalid or ignored telephone link does not emit + - generic external link emits `click` + - social share link emits `share` + +4. Existing config behavior hardening + - agency-only config sets expected defaults + - omitted site topic/platform fall back to `unspecified:` + - autotracker toggle off suppresses tracked click classes beyond downloads + +## Priority 2 + +These cover important user-visible functionality but need a bit more page interaction logic. + +1. `gas4()` event family matrix + - `social_click` + - `share` + - `navigation_click` + - `accordion_click` + - `faq_click` + - `cta_click` + - `content_view` + - `sort` + - `filter` + - `error` + - `was_this_helpful_submit` + +2. Parameter-shape resilience + - “incorrect parameter” examples still emit the supported event name + - emitted payload preserves only provided keys + - invalid event name plus valid payload falls back to `dap_event` + +3. Search and querystring handling + - page URL with search param emits `view_search_results` + - search term is preserved in event payload + - non-search query params are scrubbed from tracked page location + - allowlisted agency query params remain in tracked page location + - disallowed query params are removed + +4. Dynamic DOM tracking + - dynamically inserted links are tracked by autotracker + - dynamic downloadable link emits `file_download` + +## Priority 3 + +These are valuable but slower, more brittle, or likely to need harness work. + +1. HTML5 media tracking + - video start + - video pause + - video progress milestone + - video complete + - audio start/pause/progress/complete if applicable on page + +2. YouTube tracking + - YouTube enabled loads tracker and emits `video_start` + - emits `video_play` + - emits `video_pause` + - emits `video_progress` + - emits `video_complete` + - handles player error with `video_error` + +3. Environment/config toggles + - `youtube=true` enables YouTube tracking + - `htmlvideo=false` suppresses HTML5 media tracking + - `webvitals=true` or homepage conditions inject/report vitals behavior + - dev env switches GA property ID as expected + - production/staging query handling stays correct + +4. Parallel/custom-dimension variants + - parallel tracker custom dimension mapping + - alternate dimension names via query params + - script source / protocol / hostname dimensions present when expected + +## Priority 4 + +These are lower ROI or better suited to unit-style tests around pure helpers. + +1. URL/PII sanitization internals + - scrub email-like values from URLs + - scrub phone-like values + - nested querystring redaction + - allowed-querystring merging behavior + - object-to-query / query-to-object round trips where relied upon + +2. Defensive/error-tolerance paths + - malformed URLs do not break tracking + - missing `dataLayer` initialization path recovers + - bad media state changes do not throw + - missing banner element is harmless + +## Recommended Rollout + +1. Add `10-15` Priority 1 scenarios first. +2. Add `10-12` Priority 2 scenarios next. +3. Decide whether media/video coverage belongs in Cucumber or a thinner harness. +4. Cover the remaining helper/sanitization logic with lower-level tests if possible. diff --git a/features/autotracker_links.feature b/features/autotracker_links.feature new file mode 100644 index 0000000..4cabaaf --- /dev/null +++ b/features/autotracker_links.feature @@ -0,0 +1,54 @@ +Feature: Autotracker reports supported non-download link interactions + + Background: + Given I load an empty browser + And DAP is configured for agency "GSA" + + Scenario: Autotracker reports external mail links + Given DAP is configured with autotracking enabled + When I load the test site + And I click link 1 with text "mailto:test@domain.com" in the "Email Click" section + Then the dataLayer contains the event "email_click" + | link_url | [REDACTED_EMAIL] | + | link_domain | domain.com | + | link_text | mailto:[REDACTED_EMAIL] | + | outbound | true | + | interaction_type | Mouse Click | + + Scenario: Autotracker reports formatted telephone links + Given DAP is configured with autotracking enabled + When I load the test site + And I click link 1 with text "Telephone +1437-925-1855" in the "Telephone Click" section + Then the dataLayer contains the event "telephone_click" + | link_url | [REDACTED_TEL] | + | link_text | Telephone [REDACTED_TEL] | + | interaction_type | Mouse Click | + + Scenario: Autotracker reports generic external clicks + Given DAP is configured with autotracking enabled + When I load the test site + And I click link 1 with text "http://www.gsa.gov/travelpolicy" in the "External Links" section + Then the dataLayer contains the event "click" + | link_domain | gsa.gov | + | outbound | true | + | interaction_type | Mouse Click | + + Scenario: Autotracker reports addtoany share links + Given DAP is configured with autotracking enabled + When I load the test site + And I add an external share link to the page + And I click the injected link with selector "#dynamicShareLink" + Then the dataLayer contains the event "share" + | method | facebook | + | content_name | Travel Policy | + | shared_via | add to any: facebook | + | content_type | content | + | content_url | https://gsa.gov/travelpolicy | + | outbound | true | + | interaction_type | Mouse Click | + + Scenario: Autotracker disabled suppresses generic external clicks + Given DAP is configured with autotracking disabled + When I load the test site + And I click link 1 with text "http://www.gsa.gov/travelpolicy" in the "External Links" section + Then the dataLayer does not contain the event "click" diff --git a/features/configuration.feature b/features/configuration.feature index 9c94024..28da359 100644 --- a/features/configuration.feature +++ b/features/configuration.feature @@ -19,4 +19,13 @@ Feature: A site can load the DAP code with varying levels of customization Then DAP will set custom dimensions | agency | GSA | | site_topic | analytics | - | site_platform | cloud.gov | \ No newline at end of file + | site_platform | cloud.gov | + + Scenario: Load a DAP-enabled page with only agency uses the default custom dimensions + Given DAP is configured for agency "GSA" + When I load the test site + Then DAP will set custom dimensions + | agency | GSA | + | subagency | LOCALHOST | + | site_topic | unspecified:localhost | + | site_platform | unspecified:localhost | diff --git a/features/gas4_events.feature b/features/gas4_events.feature new file mode 100644 index 0000000..c8dedb9 --- /dev/null +++ b/features/gas4_events.feature @@ -0,0 +1,61 @@ +Feature: gas4 helper events are sent to DAP + + Background: + Given I load an empty browser + And DAP is configured for agency "GSA" + + Scenario: Clicking the official USA banner reports the banner event + When I load the test site + And I click on element with selector "#banner-button" + Then the dataLayer contains the event "official_usa_site_banner_click" + | link_text | Here’s how you know | + | section | header | + + Scenario: Submitting the form interaction example reports a form_submit event + When I load the test site + And I click the submit button in the "gas4()" example of the "gas4() - Form Interaction" section + Then the dataLayer contains the event "form_submit" + | form_name | | + | form_id | | + | form_destination | | + | section |
| + | form_submit_text | | + + Scenario: Unsupported gas4 event names fall back to dap_event + When I load the test site + And I click link 1 in the "Incorrect Event Name" example of the "gas4() - Social click" section + Then the dataLayer contains the event "dap_event" + | link_text | | + | link_domain | | + | link_url | | + | link_id | | + | link_classes | | + | social_network | | + | content_type | | + | section |
| + + Scenario: gas4 page view reports the provided page title and location + When I load the test site + And I call gas4 "page_view" with parameters + | page_location | /priority-one?page=1 | + | page_title | Priority One | + Then the dataLayer contains the event "page_view" + | page_location | http://localhost/priority-one | + | page_title | Priority One | + + Scenario: gas4 view_search_results emits the provided search term + When I load the test site + And I call gas4 "view_search_results" with parameters + | search_term | analytics | + Then the dataLayer contains the event "view_search_results" + | search_term | analytics | + + Scenario: gas4 with an empty parameter object does not emit an event + When I load the test site + And I call gas4 "share" with an empty parameter object + Then the dataLayer does not contain the event "share" + + Scenario: gas4 with malformed arguments does not emit an event + When I load the test site + And I call gas4 with malformed arguments + Then the dataLayer does not contain the event "share" diff --git a/features/gas_legacy.feature b/features/gas_legacy.feature new file mode 100644 index 0000000..42c281b --- /dev/null +++ b/features/gas_legacy.feature @@ -0,0 +1,43 @@ +Feature: gas legacy helper calls are reported to DAP + + Background: + Given I load an empty browser + And DAP is configured for agency "GSA" + + Scenario: gas pageview without title reports the current document title + When I load the test site + And I click link 1 with text "Pageview without title" in the "gas() - Custom Events / Pageviews / Custom Dimensions / Custom Metrics (Federated Only)" section + Then the dataLayer contains the event "page_view" + | page_location | http://localhost/dir/virtual-page?query=term | + | page_title | DAP test site | + + Scenario: gas pageview with title reports the provided page title + When I load the test site + And I click link 1 with text "Pageview with title" in the "gas() - Custom Events / Pageviews / Custom Dimensions / Custom Metrics (Federated Only)" section + Then the dataLayer contains the event "page_view" + | page_location | http://localhost/dir/virtual-page | + | page_title | virtual page title | + + Scenario: gas event reports a dap_event payload + When I load the test site + And I click link 1 with text "Event" in the "gas() - Custom Events / Pageviews / Custom Dimensions / Custom Metrics (Federated Only)" section + Then the dataLayer contains the event "dap_event" + | event_category | category event | + | event_action | action event | + | event_label | label event | + | event_value | 10 | + + Scenario: gas event supports non-interaction events + When I load the test site + And I click link 1 with text "Custom Dimension" in the "gas() - Custom Events / Pageviews / Custom Dimensions / Custom Metrics (Federated Only)" section + Then the dataLayer contains the event "dap_event" + | event_category | custom dimension | + | event_action | slot 9 | + | event_label | dimension value | + | event_value | 0 | + | non_interaction | true | + + Scenario: invalid gas calls do not emit a dap_event + When I load the test site + And I call gas with invalid arguments + Then the dataLayer does not contain the event "dap_event" diff --git a/features/support/step_definitions/dataLayer_steps.js b/features/support/step_definitions/dataLayer_steps.js index d6b0027..d60cc79 100644 --- a/features/support/step_definitions/dataLayer_steps.js +++ b/features/support/step_definitions/dataLayer_steps.js @@ -2,7 +2,18 @@ import { Then } from "@cucumber/cucumber"; import * as chai from 'chai' const expect = chai.expect; +async function getDataLayerEvent(page, eventName) { + return page.evaluate((eventName) => { + return [...window.dataLayer] + .reverse() + .find(item => item[0] === 'event' && item[1] === eventName); + }, eventName); +} + Then("DAP will set custom dimensions", async function (table) { + await this.page.waitForFunction(() => { + return Array.isArray(window.dataLayer) && window.dataLayer.some(item => item[0] === 'config'); + }); const configCommand = await this.page.evaluate(() => { return window.dataLayer.find(item => item[0] === 'config'); }); @@ -12,9 +23,7 @@ Then("DAP will set custom dimensions", async function (table) { }); Then("the file download is reported to DAP with interaction type {string}", async function (interactionType) { - const event = await this.page.evaluate(() => { - return window.dataLayer.find(item => item[0] === 'event' && item[1] === 'file_download'); - }); + const event = await getDataLayerEvent(this.page, 'file_download'); expect(event).to.deep.equal( { '0': 'event', @@ -35,8 +44,22 @@ Then("the file download is reported to DAP with interaction type {string}", asyn }); Then("the file download is not reported to DAP", async function () { - const event = await this.page.evaluate(() => { - return window.dataLayer.find(item => item[0] === 'event' && item[1] === 'file_download'); - }); + const event = await getDataLayerEvent(this.page, 'file_download'); + expect(event).to.be.undefined; +}); + +Then("the dataLayer contains the event {string}", async function (eventName, table) { + await this.page.waitForFunction((eventName) => { + return Array.isArray(window.dataLayer) && window.dataLayer.some(item => item[0] === 'event' && item[1] === eventName); + }, {}, eventName); + const event = await getDataLayerEvent(this.page, eventName); + expect(event).to.exist; + expect(event["0"]).to.equal("event"); + expect(event["1"]).to.equal(eventName); + expect(event["2"]).to.include(table.rowsHash()); +}); + +Then("the dataLayer does not contain the event {string}", async function (eventName) { + const event = await getDataLayerEvent(this.page, eventName); expect(event).to.be.undefined; }); diff --git a/features/support/step_definitions/interaction_steps.js b/features/support/step_definitions/interaction_steps.js index d732647..7543bd9 100644 --- a/features/support/step_definitions/interaction_steps.js +++ b/features/support/step_definitions/interaction_steps.js @@ -11,4 +11,162 @@ When("I highlight and press Enter on a file to download it", async function () { When("I click on element with selector {string}", async function (elementSelector) { await this.page.locator(elementSelector).click(); -}); \ No newline at end of file +}); + +function normalizeText(text) { + return text.replace(/\s+/g, ' ').trim(); +} + +async function clickElement(page, element) { + await page.evaluate((element) => { + element.addEventListener('click', (event) => { + event.preventDefault(); + }, { once: true }); + + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + element.click(); + }, element); +} + +async function getSectionByHeading(page, sectionHeading) { + return page.evaluateHandle((sectionHeading) => { + const sections = Array.from(document.querySelectorAll('section')); + return sections.find((candidate) => { + const heading = candidate.querySelector('h2'); + return heading && heading.textContent.replace(/\s+/g, ' ').trim() === sectionHeading; + }); + }, sectionHeading); +} + +async function clickElementInExample(page, sectionHeading, exampleHeading, selector, index = 0) { + await page.evaluate(({ sectionHeading, exampleHeading, selector, index }) => { + const normalizedText = (text) => text.replace(/\s+/g, ' ').trim(); + const sections = Array.from(document.querySelectorAll('section')); + const section = sections.find((candidate) => { + const heading = candidate.querySelector('h2'); + return heading && normalizedText(heading.textContent) === sectionHeading; + }); + + if (!section) { + throw new Error(`Section not found: ${sectionHeading}`); + } + + const columns = Array.from(section.querySelectorAll('.column')); + const column = columns.find((candidate) => { + const heading = candidate.querySelector('h4'); + return heading && normalizedText(heading.textContent) === exampleHeading; + }); + + if (!column) { + throw new Error(`Example not found: ${exampleHeading}`); + } + + const elements = Array.from(column.querySelectorAll(selector)); + const element = elements[index]; + + if (!element) { + throw new Error(`Element not found for selector "${selector}" at index ${index}`); + } + + // Keep the page in place so assertions can inspect the dataLayer after inline handlers run. + element.addEventListener('click', (event) => { + event.preventDefault(); + }, { once: true }); + + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + element.click(); + }, { sectionHeading, exampleHeading, selector, index }); +} + +async function clickMatchingElementInSection(page, sectionHeading, selector, matcherText, index = 0) { + await page.evaluate(({ sectionHeading, selector, matcherText, index }) => { + const normalizedText = (text) => text.replace(/\s+/g, ' ').trim(); + const sections = Array.from(document.querySelectorAll('section')); + const section = sections.find((candidate) => { + const heading = candidate.querySelector('h2'); + return heading && normalizedText(heading.textContent) === sectionHeading; + }); + + if (!section) { + throw new Error(`Section not found: ${sectionHeading}`); + } + + const elements = Array.from(section.querySelectorAll(selector)) + .filter((element) => normalizedText(element.textContent) === matcherText); + const element = elements[index]; + + if (!element) { + throw new Error(`Element with text "${matcherText}" not found in section "${sectionHeading}"`); + } + + element.addEventListener('click', (event) => { + event.preventDefault(); + }, { once: true }); + + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + element.click(); + }, { sectionHeading, selector, matcherText, index }); +} + +When("I click the submit button in the {string} example of the {string} section", async function (exampleHeading, sectionHeading) { + await clickElementInExample(this.page, sectionHeading, exampleHeading, 'input[type="submit"]'); +}); + +When("I click link {int} in the {string} example of the {string} section", async function (linkNumber, exampleHeading, sectionHeading) { + await clickElementInExample(this.page, sectionHeading, exampleHeading, 'a', linkNumber - 1); +}); + +When("I click link {int} with text {string} in the {string} section", async function (linkNumber, linkText, sectionHeading) { + await clickMatchingElementInSection(this.page, sectionHeading, 'a', linkText, linkNumber - 1); +}); + +When("I click button {int} with text {string} in the {string} section", async function (buttonNumber, buttonText, sectionHeading) { + await clickMatchingElementInSection(this.page, sectionHeading, 'button', buttonText, buttonNumber - 1); +}); + +When("I call gas with invalid arguments", async function () { + await this.page.evaluate(() => { + window.gas(); + }); +}); + +When("I call gas4 {string} with parameters", async function (eventName, table) { + await this.page.evaluate(({ eventName, parameters }) => { + window.gas4(eventName, parameters); + }, { eventName, parameters: table.rowsHash() }); +}); + +When("I call gas4 {string} with an empty parameter object", async function (eventName) { + await this.page.evaluate((eventName) => { + window.gas4(eventName, {}); + }, eventName); +}); + +When("I call gas4 with malformed arguments", async function () { + await this.page.evaluate(() => { + window.gas4('share'); + }); +}); + +When("I add an external share link to the page", async function () { + await this.page.evaluate(() => { + const wrapper = document.querySelector('#newLinkWrapper'); + wrapper.innerHTML = ` + + Share travel policy + + `; + }); +}); + +When("I click the injected link with selector {string}", async function (selector) { + const handle = await this.page.$(selector); + if (!handle) { + throw new Error(`Injected element not found for selector: ${selector}`); + } + await clickElement(this.page, handle); +}); diff --git a/features/support/step_definitions/loading_steps.js b/features/support/step_definitions/loading_steps.js index 90fb82f..bcc418e 100644 --- a/features/support/step_definitions/loading_steps.js +++ b/features/support/step_definitions/loading_steps.js @@ -27,5 +27,11 @@ Given("DAP is configured with autotracking disabled", function () { }); When("I load the test site", async function () { - await this.page.goto(`http://localhost:8080?${this.dapConfig.toQueryParams()}`); + await this.page.goto(`http://localhost:8080?${this.dapConfig.toQueryParams()}`, { + waitUntil: 'domcontentloaded', + timeout: 15000 + }); + await this.page.waitForFunction(() => Array.isArray(window.dataLayer), { + timeout: 15000 + }); });