diff --git a/.gitignore b/.gitignore index 56c71aef026..51bb2faf1fc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ test-html-results/ test-results/ test-a11y-results/ tmp +nala/utils/auth.json libs/navigation/dist/ *.mdc /.cursor*/ diff --git a/libs/blocks/modal/modal.css b/libs/blocks/modal/modal.css index e3b2df4b8c9..07e9f731600 100644 --- a/libs/blocks/modal/modal.css +++ b/libs/blocks/modal/modal.css @@ -379,6 +379,14 @@ html[dir="rtl"] .dialog-modal button.dialog-close { max-width: calc((100% - 6px) - 2em); } + .dialog-modal.commerce-frame.crm, + .dialog-modal.commerce-frame.d2p, + .dialog-modal.commerce-frame.twp { + width: 1030px; + max-width: 90vw; + } + + .dialog-modal.commerce-frame { width: 1024px; height: 850px; diff --git a/libs/blocks/modal/modal.merch.js b/libs/blocks/modal/modal.merch.js index cca525edd2d..d4b60cdd372 100644 --- a/libs/blocks/modal/modal.merch.js +++ b/libs/blocks/modal/modal.merch.js @@ -43,7 +43,7 @@ export function sendViewportDimensionsOnRequest(source) { window.addEventListener('resize', debounce(() => sendViewportDimensionsToIframe(source), 10)); } -function reactToMessage({ data, source }) { +function reactToMessage({ data, source }, iframe) { if (data === 'viewportWidth' && source) { /* If the page inside iframe comes from another domain, it won't be able to retrieve the viewport dimensions, so it sends a request to receive the viewport dimensions @@ -51,6 +51,23 @@ function reactToMessage({ data, source }) { sendViewportDimensionsOnRequest(source); } + /* The height of the CRM modal is not calculated properly on Titan side so it looks better + if we simply set the fixed height 875px for CRM on desktop + */ + if (iframe.src.includes('.modal.html')) { // if crm + const dialogModal = iframe.closest('.dialog-modal'); + const miloIframe = iframe.closest('.milo-iframe'); + if (!dialogModal || !miloIframe) return; + if (document.body.offsetWidth > TABLET_MAX) { + miloIframe.style.height = 'auto'; + dialogModal.style.height = 'auto'; + dialogModal.style.maxHeight = '875px'; + } else { + dialogModal.style.maxHeight = 'none'; + } + return; + } + if (data?.contentHeight) { /* If the page inside iframe sends the postMessage with its content height, we activate the height auto adjustment to eliminate the blank space at the bottom of the modal. @@ -84,5 +101,7 @@ export function adjustStyles({ dialog, iframe }) { export default async function enableCommerceFrameFeatures({ dialog, iframe }) { if (!dialog || !iframe) return; adjustStyles({ dialog, iframe }); - window.addEventListener('message', reactToMessage); + window.addEventListener('message', (e) => { + reactToMessage(e, iframe); + }); } diff --git a/libs/blocks/preflight/checks/merch.js b/libs/blocks/preflight/checks/merch.js new file mode 100644 index 00000000000..090fe63b72c --- /dev/null +++ b/libs/blocks/preflight/checks/merch.js @@ -0,0 +1,84 @@ +import { getConfig } from '../../../utils/utils.js'; +import { SEVERITY } from './constants.js'; + +const API_BASE = 'https://www.adobe.com/mas/io/fragment'; +const API_KEY = 'wcms-commerce-ims-ro-user-milo'; +const HYDRATION_DELAY_MS = 3000; + +export function findFragmentElements(area = document) { + const els = area.querySelectorAll('aem-fragment[fragment]'); + const entries = []; + els.forEach((el) => { + const uuid = el.getAttribute('fragment'); + if (!uuid) return; + const card = el.closest('merch-card, merch-card-collection, mas-field') || el; + entries.push({ uuid, el, card }); + }); + return entries; +} + +export async function checkFragmentPublished(uuid, locale) { + const params = new URLSearchParams({ id: uuid, api_key: API_KEY, locale }); + const url = `${API_BASE}?${params.toString()}`; + try { + const res = await fetch(url); + return { uuid, httpStatus: res.status, published: res.status === 200 }; + } catch { + return { uuid, httpStatus: 0, published: false }; + } +} + +function resolveLocale(locale) { + if (locale) return locale; + const ietf = getConfig()?.locale?.ietf || 'en-US'; + return ietf.replace('-', '_'); +} + +export async function checkUnpublishedFragments({ area = document, locale } = {}) { + const resolvedLocale = resolveLocale(locale); + const entries = findFragmentElements(area); + const byUuid = new Map(); + entries.forEach((entry) => { + if (!byUuid.has(entry.uuid)) byUuid.set(entry.uuid, []); + byUuid.get(entry.uuid).push(entry); + }); + + const uuids = [...byUuid.keys()]; + const results = await Promise.all( + uuids.map((uuid) => checkFragmentPublished(uuid, resolvedLocale)), + ); + + const unpublished = results + .filter((r) => !r.published) + .map((r) => { + const group = byUuid.get(r.uuid); + return { + uuid: r.uuid, + httpStatus: r.httpStatus, + elements: group.map((g) => g.el), + cards: group.map((g) => g.card), + }; + }); + + if (unpublished.length > 0) { + window.lana?.log?.(`[preflight][mas] ${unpublished.length} unpublished fragment(s) out of ${uuids.length} scanned: ${unpublished.map((u) => u.uuid).join(', ')}`, { tags: 'preflight', errorType: 'i' }); + } + + return { unpublished, scanned: uuids.length }; +} + +export function runChecks({ area = document, locale, delayMs = HYDRATION_DELAY_MS } = {}) { + return [(async () => { + if (delayMs > 0) { + await new Promise((resolve) => { setTimeout(resolve, delayMs); }); + } + const { unpublished, scanned } = await checkUnpublishedFragments({ area, locale }); + const failed = unpublished.length > 0; + return { + name: 'M@S Unpublished Fragments', + status: failed ? 'fail' : 'pass', + severity: SEVERITY.CRITICAL, + details: failed ? { unpublished, scanned } : { scanned }, + }; + })()]; +} diff --git a/libs/blocks/preflight/checks/preflightApi.js b/libs/blocks/preflight/checks/preflightApi.js index e311e6153cd..0f9cf18243e 100644 --- a/libs/blocks/preflight/checks/preflightApi.js +++ b/libs/blocks/preflight/checks/preflightApi.js @@ -27,6 +27,7 @@ import { runChecks as runChecksSeo, } from './seo.js'; import { runChecks as runChecksStructure } from './structure.js'; +import { runChecks as runChecksMerch } from './merch.js'; import { SEVERITY } from './constants.js'; let checksSuite = null; @@ -64,6 +65,7 @@ export default { runChecks: runChecksSeo, }, structure: { runChecks: runChecksStructure }, + merch: { runChecks: runChecksMerch }, }; export const getChecksSuite = () => { @@ -108,12 +110,14 @@ const runChecks = async (url, area, injectVisualMetadata = false) => { const performance = await Promise.all(runChecksPerformance(url, area)); const seo = isASO ? await fetchPreflightChecks() : runChecksSeo({ url, area }); const structure = await Promise.all(runChecksStructure({ area })); + const merch = await Promise.all(runChecksMerch({ area })); return { accessibility, assets, performance, seo, structure, + merch, }; }; @@ -150,6 +154,7 @@ export async function getPreflightResults(options = {}) { ...(res.performance || []), ...(res.seo || []), ...(res.structure || []), + ...(res.merch || []), ]; const result = { diff --git a/libs/blocks/preflight/panels/merch.js b/libs/blocks/preflight/panels/merch.js index eb57ebb2e75..2696ba7f0d9 100644 --- a/libs/blocks/preflight/panels/merch.js +++ b/libs/blocks/preflight/panels/merch.js @@ -1,9 +1,13 @@ import { html, signal, useEffect } from '../../../deps/htm-preact.js'; +import { checkUnpublishedFragments } from '../checks/merch.js'; const wcsElements = signal([]); const masFieldsMultipleFragmentWarnings = signal([]); +const unpublishedFragments = signal([]); const loading = signal(true); +const MAS_UNPUBLISHED_HIGHLIGHT = 'preflight-mas-unpublished'; + const ALLOWED_MAS_HOSTS = ['mas.adobe.com']; function isMasUrl(href) { @@ -94,6 +98,23 @@ function checkMasFieldsMultipleFragments() { masFieldsMultipleFragmentWarnings.value = warnings; } +async function checkUnpublishedFragmentsForPanel() { + const main = document.querySelector('main'); + main?.querySelectorAll(`.${MAS_UNPUBLISHED_HIGHLIGHT}`).forEach((el) => { + el.classList.remove(MAS_UNPUBLISHED_HIGHLIGHT); + }); + const { unpublished } = await checkUnpublishedFragments({ area: document }); + unpublishedFragments.value = unpublished.map((u) => { + u.cards?.forEach((c) => c.classList.add(MAS_UNPUBLISHED_HIGHLIGHT)); + const firstCard = u.cards?.[0]; + return { + uuid: u.uuid, + httpStatus: u.httpStatus, + location: firstCard ? getBlockLocation(firstCard) : 0, + }; + }); +} + function getService() { return document.getElementsByTagName('mas-commerce-service')?.[0]; } @@ -442,6 +463,42 @@ function MasFieldsMultipleFragmentSection() { `; } +function UnpublishedFragmentItem({ entry }) { + return html` +
+
+

+ + Unpublished M@S fragment +

+

+ Fragment ID: ${entry.uuid} +
HTTP status: ${entry.httpStatus} +

+ +
+
+ `; +} + +function UnpublishedFragmentsSection() { + const entries = unpublishedFragments.value; + if (entries.length === 0) return null; + return html` +
+

M@S Unpublished Fragments

+

These fragments are referenced on this page but are not published. Publishing now will break the live page.

+
+ ${entries.map((entry) => html`<${UnpublishedFragmentItem} entry=${entry} />`)} +
+
+ `; +} + function MerchSummary() { const totalElements = wcsElements.value.length; const passedCount = wcsElements.value.filter((elem) => (elem.urlStatus === 'success' || !elem.href) @@ -451,8 +508,9 @@ function MerchSummary() { || elem.promoCodeStatus === 'not-found').length; const undeterminedCount = wcsElements.value.filter((elem) => elem.urlStatus === 'undetermined').length; const masWarningsCount = masFieldsMultipleFragmentWarnings.value.length; + const unpublishedCount = unpublishedFragments.value.length; - if (totalElements === 0 && masWarningsCount === 0) { + if (totalElements === 0 && masWarningsCount === 0 && unpublishedCount === 0) { return html`

No Merch Elements Found

@@ -487,6 +545,12 @@ function MerchSummary() { Blocks w/ multiple fragment IDs
`} + ${unpublishedCount > 0 && html` +
+ ${unpublishedCount} + Unpublished M@S fragments +
+ `} `; } @@ -496,6 +560,7 @@ export default function Merch() { checkMasFieldsMultipleFragments(); setTimeout(() => { checkWcsElements(); + checkUnpublishedFragmentsForPanel(); }, 3000); }, []); @@ -512,6 +577,7 @@ export default function Merch() {
<${MerchSummary} /> <${MasFieldsMultipleFragmentSection} /> + <${UnpublishedFragmentsSection} />
`; } @@ -520,6 +586,7 @@ export default function Merch() {
<${MerchSummary} /> <${MasFieldsMultipleFragmentSection} /> + <${UnpublishedFragmentsSection} />

Elements

diff --git a/libs/blocks/preflight/preflight.css b/libs/blocks/preflight/preflight.css index 1817478f3d0..2302c07a92a 100644 --- a/libs/blocks/preflight/preflight.css +++ b/libs/blocks/preflight/preflight.css @@ -279,7 +279,7 @@ span.preflight-time { transition: outline 300ms; border: none; height: 36px; - font-family: 'Adobe Clean', adobe-clean, sans-serif; + font-family: var(--body-font-family); font-size: 16px; padding: 0 18px; clip-path: polygon(0% 0%, var(--notch-size) 0%, calc(100% - var(--notch-size)) 0%, 100% var(--notch-size), 100% calc(100% - var(--notch-size)), 100% 100%, 0% 100%, 0% 100%); @@ -1280,4 +1280,38 @@ img:hover ~ .asset-meta, border: 3px solid #e68600 !important; } +/* Highlight cards whose M@S fragment is unpublished */ +.preflight-mas-unpublished { + position: relative !important; + outline: 4px solid #f44 !important; + background-image: repeating-linear-gradient( + 135deg, + rgb(255 68 68 / 8%) 0, + rgb(255 68 68 / 8%) 8px, + transparent 8px, + transparent 16px + ) !important; +} + +.preflight-mas-unpublished::before { + content: 'Not published'; + position: absolute; + top: -1px; + right: -1px; + z-index: 2; + padding: 4px 10px; + background: #f44; + color: #fff; + font-family: var(--body-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border-top-right-radius: 8px; + border-bottom-left-radius: 8px; + box-shadow: 0 1px 4px rgb(0 0 0 / 20%); + pointer-events: none; + line-height: 2.5; +} + diff --git a/libs/utils/preflight-notification.js b/libs/utils/preflight-notification.js index 998cec7310f..49a937995ca 100644 --- a/libs/utils/preflight-notification.js +++ b/libs/utils/preflight-notification.js @@ -10,20 +10,32 @@ function openPreflightPanel() { sidekick.dispatchEvent(new CustomEvent('custom:preflight', { bubbles: true })); } -async function createPreflightNotification() { +function getMasUnpublishedCount(results) { + const merchResults = results?.runChecks?.merch || []; + return merchResults.reduce((sum, check) => { + if (check?.status !== 'fail') return sum; + return sum + (check.details?.unpublished?.length || 0); + }, 0); +} + +async function createPreflightNotification(masUnpublishedCount = 0) { const existingNotification = document.querySelector('.milo-preflight-overlay'); if (existingNotification) return; const { miloLibs, codeRoot } = getConfig(); const base = miloLibs || codeRoot; loadStyle(`${base}/styles/preflight-notification.css`); + const masLine = masUnpublishedCount > 0 + ? `
M@S: ${masUnpublishedCount} unpublished fragment${masUnpublishedCount === 1 ? '' : 's'} on this page.` + : ''; + const overlay = document.createElement('div'); overlay.className = 'milo-preflight-overlay'; overlay.innerHTML = `
- Content quality checks are failing. Please before publishing. + Content quality checks are failing. Please before publishing.${masLine}
@@ -82,7 +94,7 @@ function createObserver() { url: window.location.href, area: document, }).catch(() => null); - if (results?.hasFailures) await createPreflightNotification(); + if (results?.hasFailures) await createPreflightNotification(getMasUnpublishedCount(results)); }); sidekickObserver.observe(sidekick, { @@ -113,7 +125,7 @@ export default async function show() { if (!results) return; if (results.hasFailures) { - await createPreflightNotification(); + await createPreflightNotification(getMasUnpublishedCount(results)); } else { setupLinkCheckListener(); } diff --git a/nala/features/dafloodgate/README.md b/nala/features/dafloodgate/README.md new file mode 100644 index 00000000000..8e49dc0faca --- /dev/null +++ b/nala/features/dafloodgate/README.md @@ -0,0 +1,168 @@ +# Floodgate for DA — Nala E2E Tests + +End-to-end Playwright tests for the **Floodgate for DA** tool (`MWPW-189268`). +Tests run against `da.live` with the `da-floodgate` branch. + +> **Tag**: All tests carry `@nopr` and are skipped by the default PR workflow +> (`run-nala-default.yml`). Run them locally or in a manual job. + +--- + +## Prerequisites + +- Node 20+ +- Adobe SSO test account with **full access** to the `adobecom/da-events` repo +- Floodgate config at `/{org}/{repo}/.milo/floodgate/config.json` listing your + account in `allAccessUsers` and at least one color (e.g. `pink`) + +--- + +## Quick Start + +```bash +# Install (only first time on this repo) +npm ci +npx playwright install + +# 1. Log in to DA (one time — saves auth.json which is gitignored) +node nala/utils/da-login.js + +# 2. Seed real event content into the test sandbox (one time, or after teardown) +node nala/features/dafloodgate/seed-real-content.js seed + +# 3. Verify the sandbox has the expected files +node nala/features/dafloodgate/setup-test-data.js + +# 4. Run tests +npx playwright test nala/features/dafloodgate/ \ + --project=milo-live-chromium --workers=1 --grep "@smoke" +``` + +--- + +## Run Tests + +> Always use `--workers=1`. Tests share state in the FG repo and parallel +> workers race each other. + +```bash +# Smoke (~2 min, 10 tests) +npx playwright test nala/features/dafloodgate/ \ + --project=milo-live-chromium --workers=1 --grep "@smoke" + +# All copy tests (~4 min, 20 tests) +npx playwright test nala/features/dafloodgate/ \ + --project=milo-live-chromium --workers=1 --grep "@fg-copy" + +# Full suite (~7 min, 51 tests) +npx playwright test nala/features/dafloodgate/ \ + --project=milo-live-chromium --workers=1 + +# Headed (watch the browser) +npx playwright test nala/features/dafloodgate/ \ + --project=milo-live-chromium --workers=1 --headed --grep "@smoke" +``` + +--- + +## Test Tags + +| Tag | What | +|-----|------| +| `@smoke` | 10 must-pass tests covering the happy path | +| `@regression` | Full regression set (~37 tests) | +| `@floodgate` | All Floodgate-DA tests | +| `@nopr` | Excluded from PR auto-runs (set on every test in this directory) | +| `@fg-copy-*` | Copy workflow tests | +| `@fg-promote-*` | Promote workflow tests | +| `@fg-delete-*` | Delete workflow tests | +| `@fg-e2e-chain-*` | Full Copy → Promote → Delete chain | + +--- + +## Files + +| File | Purpose | +|------|---------| +| `floodgate.page.js` | Page Object Model with iframe + shadow DOM helpers | +| `floodgate.spec.js` | Test case definitions (51 cases) | +| `floodgate.test.js` | Playwright test implementations | +| `seed-real-content.js` | Seeds 5 real event pages + fragments into the sandbox | +| `setup-test-data.js` | Verifies sandbox state | + +Auth artefact (gitignored, generated locally): +- `nala/utils/auth.json` — Playwright `storageState` from `da-login.js` + +--- + +## Test Sandbox + +Tests run against: + +- **Source repo**: `/adobecom/da-events/drafts/nala-fg-test/` +- **Floodgate repo**: `/adobecom/da-events-fg-pink/drafts/nala-fg-test/` +- **Tool URL**: `https://da.live/#/adobecom/da-events/tools/floodgate?ref=da-floodgate` + +The seeder populates this content (run-once or after `cleanup`): + +``` +drafts/nala-fg-test/ +├── test1-single-block.html, test2-multiple-blocks.html, ... +├── google.link, test1.png, test2.png +├── assets/001.png +├── fragments/test-fragment-1.html +├── _fragments/... (static-href referenced fragments) +└── events/ + ├── summit-london.html, summit-munich.html, ... + ├── creative-cafe-ny.html, creator-live-london.html, events-hub.html + └── fragments/2026-XX-XX/ (date-based fragments loaded by chrono-box) +``` + +--- + +## Common Tasks + +### Reset the sandbox + +```bash +node nala/features/dafloodgate/seed-real-content.js cleanup # remove seeded events +node nala/features/dafloodgate/seed-real-content.js seed # repopulate +``` + +### Re-login (when auth.json expires, ~24h) + +```bash +node nala/utils/da-login.js +``` + +### Point tests at a different repo + +```bash +FG_ORG=adobecom FG_REPO=da-events FG_REF=da-floodgate FG_COLOR=pink \ + npx playwright test nala/features/dafloodgate/ \ + --project=milo-live-chromium --workers=1 --grep "@smoke" +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| All tests fail with `401` | `auth.json` expired → run `node nala/utils/da-login.js` | +| `Find Files` button stays disabled | Sandbox missing files → run `seed` | +| Delete test fails with "element not enabled" | Prior delete cleared the file → re-run `seed` or use `--workers=1` | +| iframe not found | da.live UI changed → check `floodgate.page.js` `initFrame()` | +| Locator times out | UI structure changed → check selector against shadow DOM | + +--- + +## Notes + +- **Auth.json is gitignored** — every contributor runs `da-login.js` locally. +- **Safe sandbox**: All operations target `/drafts/nala-fg-test/`. Drafts are + staging-only — they don't get published to production by design — so the + Copy / Promote / Delete tests are safe to run repeatedly. +- **CI**: `@nopr` tag keeps these out of `run-nala-default.yml`. Run manually. + +Owner: Jacky Sun · JIRA: `MWPW-189268` diff --git a/nala/features/dafloodgate/floodgate.page.js b/nala/features/dafloodgate/floodgate.page.js new file mode 100644 index 00000000000..a59d115e964 --- /dev/null +++ b/nala/features/dafloodgate/floodgate.page.js @@ -0,0 +1,385 @@ +import { expect } from '@playwright/test'; + +const DA_LIVE_URL = 'https://da.live'; +const FG_PATH = '/app/adobecom/milo/tools/floodgate'; +const DA_ADMIN = 'https://admin.da.live'; + +export default class FloodgatePage { + constructor(page) { + this.page = page; + } + + // --- Navigation & Frame --- + + static getUrl(ref = '') { + const params = ref ? `?ref=${ref}` : ''; + return `${DA_LIVE_URL}${FG_PATH}${params}`; + } + + async navigate(ref = '') { + await this.page.goto(FloodgatePage.getUrl(ref)); + await this.page.waitForLoadState('domcontentloaded'); + await this.dismissPopup(); + await this.initFrame(); + } + + async dismissPopup() { + const continueBtn = this.page.locator('.disclaimer sl-button[name="continue"]'); + try { + await continueBtn.click({ timeout: 8000 }); + } catch { + // No popup + } + } + + async initFrame() { + // da.live loads tools in an iframe + await this.page.locator('iframe').waitFor({ state: 'attached', timeout: 30000 }); + this.frame = this.page.frameLocator('iframe'); + this.initLocators(this.frame); + } + + initLocators(root) { + // milo-floodgate is a LitElement with shadow DOM + this.component = root.locator('milo-floodgate'); + + // Top-level UI + this.title = this.component.locator('h1'); + this.subtitle = this.component.locator('h3'); + + // Form controls + this.pathsTextarea = this.component.locator('textarea[name="paths"]'); + this.actionSelect = this.component.locator('.action-select'); + this.colorSelect = this.component.locator('.color-select'); + + // Buttons + this.findButton = this.component.locator('button.accent').filter({ hasText: /Find Files|Finding/ }); + this.clearButton = this.component.locator('.clear-button'); + + // Repo info + this.repoInfo = this.component.locator('.repo-info'); + this.sourceRepo = this.repoInfo.locator('p').filter({ hasText: 'Source' }).locator('span'); + this.fgRepo = this.repoInfo.locator('p').filter({ hasText: 'Floodgate' }).locator('span'); + + // Validation + this.pathCount = this.component.locator('.path-count'); + this.errorMessage = this.component.locator('.error-message'); + this.accessInfoMessage = this.component.locator('.access-info-message'); + this.invalidPathsHint = this.component.locator('.invalid-paths-hint'); + + // Toggles + this.previewAfterCopy = this.component.locator('#previewAfterCopy'); + this.publishAfterPromote = this.component.locator('#publish'); + + // Tab UI (workflow progress) + this.tabNav = this.component.locator('.tab-nav'); + this.workflowShell = this.component.locator('.workflow-shell'); + this.tabs = this.component.locator('.tabs'); + + // Find step + this.findStep = this.component.locator('.tab-step[data-id="find"]'); + this.fileList = this.findStep.locator('.url-checklist li'); + this.notFoundList = this.findStep.locator('.not-found-list li'); + + // Action buttons within find step (Copy/Promote/Delete) + this.copyButton = this.component.locator('button.accent').filter({ hasText: 'Copy' }); + this.promoteButton = this.component.locator('button.accent').filter({ hasText: 'Promote' }); + this.deleteButton = this.component.locator('button.accent').filter({ hasText: 'Delete' }); + this.cancelButton = this.component.locator('button').filter({ hasText: 'Cancel' }); + + // Badges — rendered as .detail-card with h3 title + p value + // In-progress steps have specific classes like .detail-card-success, .detail-card-total + this.badges = this.component.locator('.detail-card'); + this.successBadge = this.component.locator('.detail-card-success p'); + this.totalBadge = this.component.locator('.detail-card-total p'); + this.remainingBadge = this.component.locator('.detail-card-remaining p'); + + // Error/detail lists + this.detailLists = this.component.locator('.detail-lists'); + this.errorDetails = this.component.locator('details'); + + // Confirm Dialog + this.dialogOverlay = this.component.locator('.dialog-overlay'); + this.dialogBox = this.component.locator('.dialog-box'); + this.dialogMessage = this.dialogBox.locator('p'); + this.dialogCancelBtn = this.dialogBox.locator('button.accent').filter({ hasText: 'Cancel' }); + this.dialogContinueBtn = this.dialogBox.locator('button').filter({ hasText: /Continue|Delete/ }); + + // Done step + this.doneStep = this.component.locator('.tab-step[data-id="done"]'); + this.doneBanner = this.doneStep.locator('.done-banner p'); + this.doneCards = this.doneStep.locator('.done-cards .detail-card'); + this.retryButton = this.component.locator('button').filter({ hasText: 'Retry' }); + this.startOverButton = this.component.locator('button').filter({ hasText: 'Start Over' }); + + // Promote ignore + this.promoteIgnoreCheckbox = this.component.locator('#promoteIgnore'); + this.promoteIgnoreTextarea = this.component.locator('textarea[name="promote-ignore-paths"]'); + } + + // --- Actions --- + + async selectOperation(op) { + // op: 'fgCopy' | 'fgPromote' | 'fgDelete' + await this.actionSelect.selectOption(op); + await this.page.waitForTimeout(300); + } + + async selectColor(color) { + await this.colorSelect.selectOption(color); + await this.page.waitForTimeout(300); + } + + async enterPaths(paths) { + const text = Array.isArray(paths) ? paths.join('\n') : paths; + await this.pathsTextarea.fill(text); + // trigger input event for debounced validation + await this.pathsTextarea.dispatchEvent('input'); + // Wait for debounced access eval (300ms) + config load + await this.page.waitForTimeout(1000); + } + + async clickFind() { + await this.findButton.click(); + } + + async clickCopy() { + await this.copyButton.click(); + } + + async clickPromote() { + await this.promoteButton.click(); + } + + async clickDelete() { + await this.deleteButton.click(); + } + + async clickCancel() { + await this.cancelButton.click(); + } + + async clickClear() { + await this.clearButton.click(); + } + + async confirmDialog() { + await this.dialogContinueBtn.click(); + } + + async cancelDialog() { + await this.dialogCancelBtn.click(); + } + + async clickRetry() { + await this.retryButton.click(); + } + + async clickStartOver() { + await this.startOverButton.click(); + } + + async togglePreviewAfterCopy(checked = true) { + const current = await this.previewAfterCopy.isChecked(); + if (current !== checked) { + await this.previewAfterCopy.click(); + } + } + + async togglePublishAfterPromote(checked = true) { + const current = await this.publishAfterPromote.isChecked(); + if (current !== checked) { + await this.publishAfterPromote.click(); + } + } + + // --- Waits --- + + async waitForFindComplete(timeout = 60000) { + // Wait for Find step to show file list or action button + await expect(this.findStep).toBeVisible({ timeout }); + // Wait for find button to not say "Finding..." + await expect(this.findButton).not.toContainText('Finding', { timeout }); + } + + async waitForDone(timeout = 120000) { + await expect(this.doneStep).toBeVisible({ timeout }); + } + + async waitForDialog(timeout = 10000) { + await expect(this.dialogOverlay).toBeVisible({ timeout }); + } + + // --- Assertions --- + + async getPathCount() { + const text = await this.pathCount.textContent(); + return parseInt(text, 10); + } + + async getSuccessCount() { + // Prefer the Done banner ("X files processed ...") — most reliable after waitForDone() + try { + await this.doneBanner.waitFor({ state: 'visible', timeout: 30000 }); + const bannerText = await this.doneBanner.textContent(); + const m = bannerText && bannerText.match(/(\d+)\s+files?\s+processed/i); + if (m) return parseInt(m[1], 10); + } catch { /* fall through */ } + + // Fallback: read first done card's

text (format: "N/total") + try { + const firstCard = this.doneCards.first(); + await firstCard.waitFor({ state: 'visible', timeout: 10000 }); + const cardText = await firstCard.locator('p').first().textContent(); + const m = cardText && cardText.match(/(\d+)/); + if (m) return parseInt(m[1], 10); + } catch { /* fall through */ } + + return 0; + } + + async isStartEnabled() { + const disabled = await this.findButton.getAttribute('disabled'); + return disabled === null; + } + + // --- DA API Verification --- + + async getDaToken() { + // Extract IMS access token from da.live localStorage (populated by storageState) + return this.page.evaluate(() => { + for (const key of Object.keys(localStorage)) { + if (key.startsWith('adobeid_ims_access_token')) { + try { + const v = JSON.parse(localStorage.getItem(key)); + return v.tokenValue || v.token; + } catch { return localStorage.getItem(key); } + } + } + return null; + }); + } + + async checkFileExists(path, token) { + const url = `${DA_ADMIN}/source${path}`; + const response = await this.page.request.head(url, { headers: { Authorization: `Bearer ${token}` } }); + return response.status() === 200; + } + + async getFileContent(path, token) { + const url = `${DA_ADMIN}/source${path}`; + const response = await this.page.request.get(url, { headers: { Authorization: `Bearer ${token}` } }); + if (response.status() !== 200) return null; + return response.text(); + } + + async getFileBytes(path, token) { + const url = `${DA_ADMIN}/source${path}`; + const response = await this.page.request.get(url, { headers: { Authorization: `Bearer ${token}` } }); + if (response.status() !== 200) return null; + return response.body(); + } + + async deleteFile(path, token) { + const url = `${DA_ADMIN}/source${path}`; + const response = await this.page.request.delete(url, { headers: { Authorization: `Bearer ${token}` } }); + return response.status(); + } + + /** + * Compare source file content to FG copy, normalizing expected URL rewrites. + * The floodgate Copy step rewrites `main--{repo}--{org}` to `main--{repo}-fg-{color}--{org}` + * in HTML content. To verify round-trip integrity, we reverse that rewrite on FG + * content and compare with source. + * + * @param {string} sourcePath DA source path (e.g. /adobecom/da-events/drafts/.../page.html) + * @param {string} fgPath DA FG path (e.g. /adobecom/da-events-fg-pink/drafts/.../page.html) + * @param {object} opts { org, repo, color } used to build the URL rewrite pattern + * @returns {Promise<{identical: boolean, sourceLength: number, fgLength: number, + * diffLines?: Array, reason?: string}>} + */ + async compareSourceVsFg(sourcePath, fgPath, opts) { + const token = await this.getDaToken(); + const sourceContent = await this.getFileContent(sourcePath, token); + const fgContent = await this.getFileContent(fgPath, token); + + if (!sourceContent) return { identical: false, reason: `Source missing: ${sourcePath}` }; + if (!fgContent) return { identical: false, reason: `FG missing: ${fgPath}` }; + + // Reverse the floodgate URL rewrite: fg-{color} back to original + const { org = 'adobecom', repo = 'da-events', color = 'pink' } = opts || {}; + const fgHost = `--${repo}-fg-${color}--${org}`; + const srcHost = `--${repo}--${org}`; + const normalizedFg = fgContent.split(fgHost).join(srcHost); + + const identical = normalizedFg === sourceContent; + const result = { + identical, + sourceLength: sourceContent.length, + fgLength: fgContent.length, + normalizedFgLength: normalizedFg.length, + }; + + if (!identical) { + // Collect up to first 5 differing lines for debug + const srcLines = sourceContent.split('\n'); + const fgLines = normalizedFg.split('\n'); + const diffs = []; + const max = Math.max(srcLines.length, fgLines.length); + for (let i = 0; i < max && diffs.length < 5; i += 1) { + if (srcLines[i] !== fgLines[i]) { + diffs.push({ + line: i + 1, + source: (srcLines[i] || '').substring(0, 200), + fg: (fgLines[i] || '').substring(0, 200), + }); + } + } + result.diffLines = diffs; + } + + return result; + } + + /** + * Ensure an FG file exists by copying source -> FG via DA admin API. + * Use to stabilize delete tests when earlier tests may have removed the target. + * @param {string} sourcePath e.g. /adobecom/da-events/drafts/nala-fg-test/test1-single-block.html + * @param {string} fgPath e.g. /adobecom/da-events-fg-pink/drafts/nala-fg-test/test1-single-block.html + * @param {string} token DA bearer token + */ + async ensureFileInFg(sourcePath, fgPath, token) { + // Already exists? Done. + if (await this.checkFileExists(fgPath, token)) return; + + // Fetch source content + const src = await this.page.request.get(`${DA_ADMIN}/source${sourcePath}`, { headers: { Authorization: `Bearer ${token}` } }); + if (src.status() !== 200) return; + + const isHtml = sourcePath.endsWith('.html'); + const isJson = sourcePath.endsWith('.json'); + + if (isHtml || isJson) { + // Create version first (required for editable files) + await this.page.request.post(`${DA_ADMIN}/versionsource${fgPath}`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: JSON.stringify({ label: 'Nala test restore' }), + }); + } + + const content = isHtml || isJson ? await src.text() : await src.body(); + let mimeType = 'application/octet-stream'; + if (isHtml) mimeType = 'text/html'; + else if (isJson) mimeType = 'application/json'; + await this.page.request.post(`${DA_ADMIN}/source${fgPath}`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + data: { + name: 'data', + mimeType, + buffer: Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'), + }, + }, + }); + } +} diff --git a/nala/features/dafloodgate/floodgate.spec.js b/nala/features/dafloodgate/floodgate.spec.js new file mode 100644 index 00000000000..40cff889d27 --- /dev/null +++ b/nala/features/dafloodgate/floodgate.spec.js @@ -0,0 +1,516 @@ +const TEST_REF = process.env.FG_REF || 'da-floodgate'; +const TEST_ORG = process.env.FG_ORG || 'adobecom'; +const TEST_REPO = process.env.FG_REPO || 'da-events'; +const FG_COLOR = process.env.FG_COLOR || 'pink'; + +// Test sandbox in the source repo +const TEST_DIR = `/${TEST_ORG}/${TEST_REPO}/drafts/nala-fg-test`; +const FG_DIR = `/${TEST_ORG}/${TEST_REPO}-fg-${FG_COLOR}/drafts/nala-fg-test`; + +// --------------------------------------------------------------------------- +// Test file inventory +// --------------------------------------------------------------------------- + +// Tier 1: Simple authored test files (created manually) +const SIMPLE = { + singleBlock: `${TEST_DIR}/test1-single-block`, + multipleBlocks: `${TEST_DIR}/test2-multiple-blocks`, + sheetJson: `${TEST_DIR}/test3-sheet.json`, + withFragments: `${TEST_DIR}/test4-with-fragments`, + datedPage: `${TEST_DIR}/2024-11-14`, + fragment: `${TEST_DIR}/fragments/test-fragment-1`, + imageRoot: `${TEST_DIR}/test1.png`, + imageAssets: `${TEST_DIR}/assets/001.png`, + linkFile: `${TEST_DIR}/google.link`, +}; + +// Tier 2: Real event content (seeded via seed-real-content.js) +// Each event page has known characteristics useful for specific test scenarios. +const EVENTS = { + summitLondon: `${TEST_DIR}/events/summit-london`, // 31KB, 1 fragment, 3 jpg — standard UK event + summitMunich: `${TEST_DIR}/events/summit-munich`, // 32KB, 1 fragment, 3 img — DE locale + creativeCafeNy: `${TEST_DIR}/events/creative-cafe-ny`, // 27KB, 2 fragments (local + shared) — fragment discovery + creatorLive: `${TEST_DIR}/events/creator-live-london`, // 68KB, 1 fragment, 3 png — largest page + eventsHub: `${TEST_DIR}/events/events-hub`, // 16KB, CaaS dynamic content — hub page +}; + +module.exports = { + FeatureName: 'DA Floodgate', + testRef: TEST_REF, + testOrg: TEST_ORG, + testRepo: TEST_REPO, + fgColor: FG_COLOR, + testDir: TEST_DIR, + fgDir: FG_DIR, + files: SIMPLE, + events: EVENTS, + + features: [ + // ============================================================ + // Suite A: Page Load & Auth + // ============================================================ + { + tcid: 'A1', + name: '@fg-page-load', + tags: '@smoke @floodgate @nopr', + desc: 'Load Floodgate tool after DA login', + }, + { + tcid: 'A2', + name: '@fg-default-copy', + tags: '@smoke @floodgate @nopr', + desc: 'Default state is Copy operation', + }, + { + tcid: 'A3', + name: '@fg-switch-promote', + tags: '@smoke @floodgate @nopr', + desc: 'Switch to Promote operation', + }, + { + tcid: 'A4', + name: '@fg-switch-delete', + tags: '@smoke @floodgate @nopr', + desc: 'Switch to Delete operation', + }, + + // ============================================================ + // Suite B: Path Validation + // ============================================================ + { + tcid: 'B1', + name: '@fg-valid-paths', + tags: '@regression @floodgate @nopr', + desc: 'Valid paths enable Start button', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'B2', + name: '@fg-invalid-short-path', + tags: '@regression @floodgate @nopr', + desc: 'Path with < 3 parts disables Start', + data: { paths: ['/org-only'] }, + }, + { + tcid: 'B3', + name: '@fg-reject-fg-path', + tags: '@regression @floodgate @nopr', + desc: 'Paths with -fg- in repo are rejected', + data: { paths: [`/${TEST_ORG}/${TEST_REPO}-fg-pink/drafts/nala-fg-test/test1-single-block`] }, + }, + { + tcid: 'B4', + name: '@fg-mixed-org-rejected', + tags: '@regression @floodgate @nopr', + desc: 'Mixed org/repo paths are rejected', + data: { paths: [SIMPLE.singleBlock, '/other-org/other-repo/drafts/page2'] }, + }, + { + tcid: 'B5', + name: '@fg-aem-url-convert', + tags: '@regression @floodgate @nopr', + desc: 'AEM preview URL auto-converted to path', + data: { aemUrl: `https://main--${TEST_REPO}--${TEST_ORG}.aem.page/drafts/nala-fg-test/test1-single-block` }, + }, + { + tcid: 'B6', + name: '@fg-wildcard-path', + tags: '@regression @floodgate @nopr', + desc: 'Wildcard path (*) accepted', + data: { paths: [`${TEST_DIR}/*`] }, + }, + { + tcid: 'B7', + name: '@fg-repo-info', + tags: '@regression @floodgate @nopr', + desc: 'Repo info shows source and FG repo', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'B8', + name: '@fg-session-storage', + tags: '@regression @floodgate @nopr', + desc: 'Paths persist in sessionStorage across reload', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'B9', + name: '@fg-clear-button', + tags: '@regression @floodgate @nopr', + desc: 'Clear button empties textarea and resets state', + data: { paths: [SIMPLE.singleBlock] }, + }, + + // ============================================================ + // Suite C: Copy Workflow — Simple Content + // ============================================================ + { + tcid: 'C1', + name: '@fg-copy-single-block', + tags: '@smoke @floodgate @nopr', + desc: 'Copy single-block HTML page', + data: { + paths: [SIMPLE.singleBlock], + expectedFgPath: `${FG_DIR}/test1-single-block.html`, + }, + }, + { + tcid: 'C2', + name: '@fg-copy-multiple-blocks', + tags: '@regression @floodgate @nopr', + desc: 'Copy HTML page with multiple blocks', + data: { + paths: [SIMPLE.multipleBlocks], + expectedFgPath: `${FG_DIR}/test2-multiple-blocks.html`, + }, + }, + { + tcid: 'C3', + name: '@fg-copy-with-fragments', + tags: '@regression @floodgate @nopr', + desc: 'Copy page containing fragment references', + data: { + paths: [SIMPLE.withFragments], + expectedFragmentDiscovered: true, + }, + }, + { + tcid: 'C4', + name: '@fg-copy-json', + tags: '@regression @floodgate @nopr', + desc: 'Copy JSON sheet file', + data: { + paths: [SIMPLE.sheetJson], + expectedFgPath: `${FG_DIR}/test3-sheet.json`, + }, + }, + { + tcid: 'C5', + name: '@fg-copy-image', + tags: '@regression @floodgate @nopr', + desc: 'Copy PNG image file', + data: { + paths: [SIMPLE.imageRoot], + expectedFgPath: `${FG_DIR}/test1.png`, + }, + }, + { + tcid: 'C6', + name: '@fg-copy-nested-asset', + tags: '@regression @floodgate @nopr', + desc: 'Copy asset from subfolder (/assets/)', + data: { + paths: [SIMPLE.imageAssets], + expectedFgPath: `${FG_DIR}/assets/001.png`, + }, + }, + { + tcid: 'C7', + name: '@fg-copy-link-file', + tags: '@regression @floodgate @nopr', + desc: 'Copy .link file (known gap: google.link missing in previous FG copy)', + data: { + paths: [SIMPLE.linkFile], + expectedFgPath: `${FG_DIR}/google.link`, + }, + }, + { + tcid: 'C8', + name: '@fg-copy-preview-toggle', + tags: '@regression @floodgate @nopr', + desc: 'Preview after copy toggle works', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'C9', + name: '@fg-copy-wildcard', + tags: '@regression @floodgate @nopr', + desc: 'Wildcard expansion copies all files', + data: { + paths: [`${TEST_DIR}/*`], + expectedMinFiles: 10, + }, + }, + { + tcid: 'C10', + name: '@fg-copy-remove-file', + tags: '@regression @floodgate @nopr', + desc: 'Remove file from list before copying', + data: { paths: [SIMPLE.singleBlock, SIMPLE.multipleBlocks] }, + }, + + // ============================================================ + // Suite C+: Copy Workflow — Real Event Content + // ============================================================ + { + tcid: 'C11', + name: '@fg-copy-event-simple', + tags: '@smoke @floodgate @nopr', + desc: 'Copy real UK event page (Summit London) with 1 local fragment', + data: { + paths: [EVENTS.summitLondon], + expectedFgPath: `${FG_DIR}/events/summit-london.html`, + expectedFragmentCount: 1, + }, + }, + { + tcid: 'C12', + name: '@fg-copy-event-shared-fragment', + tags: '@regression @floodgate @nopr', + desc: 'Copy Creative Cafe NY — verify shared fragment discovery (2 fragments total)', + data: { + paths: [EVENTS.creativeCafeNy], + expectedFragmentCount: 2, + }, + }, + { + tcid: 'C13', + name: '@fg-copy-large-page', + tags: '@regression @floodgate @nopr', + desc: 'Copy Creator Live London — largest page (~68KB) with full block stack', + data: { + paths: [EVENTS.creatorLive], + expectedFgPath: `${FG_DIR}/events/creator-live-london.html`, + expectedFragmentCount: 1, + }, + }, + { + tcid: 'C14', + name: '@fg-copy-caas-page', + tags: '@regression @floodgate @nopr', + desc: 'Copy Events Hub — verify CaaS encoded blocks preserved in FG repo', + data: { + paths: [EVENTS.eventsHub], + expectedFgPath: `${FG_DIR}/events/events-hub.html`, + expectedContentContains: 'caas#~~H4sIAA', + }, + }, + { + tcid: 'C15', + name: '@fg-copy-multi-locale', + tags: '@regression @floodgate @nopr', + desc: 'Copy UK + DE event pages in same batch', + data: { + paths: [EVENTS.summitLondon, EVENTS.summitMunich], + expectedMinFiles: 2, + }, + }, + { + tcid: 'C16', + name: '@fg-copy-events-wildcard', + tags: '@regression @floodgate @nopr', + desc: 'Wildcard on events/ subfolder copies all 5 real event pages', + data: { + paths: [`${TEST_DIR}/events/*`], + expectedMinFiles: 5, + }, + }, + + // Content integrity — compare source vs FG after copy (normalizing URL rewrites) + { + tcid: 'C17', + name: '@fg-copy-integrity-simple', + tags: '@regression @floodgate @nopr', + desc: 'Copy simple page — source and FG content identical (modulo URL rewrite)', + data: { + paths: [SIMPLE.singleBlock], + sourcePath: `${TEST_DIR}/test1-single-block.html`, + fgPath: `${FG_DIR}/test1-single-block.html`, + }, + }, + { + tcid: 'C18', + name: '@fg-copy-integrity-event', + tags: '@regression @floodgate @nopr', + desc: 'Copy real event page — source and FG content byte-identical after URL normalization', + data: { + paths: [EVENTS.summitLondon], + sourcePath: `${TEST_DIR}/events/summit-london.html`, + fgPath: `${FG_DIR}/events/summit-london.html`, + }, + }, + { + tcid: 'C19', + name: '@fg-copy-integrity-caas', + tags: '@regression @floodgate @nopr', + desc: 'Copy CaaS hub page — encoded payload survives copy byte-perfect', + data: { + paths: [EVENTS.eventsHub], + sourcePath: `${TEST_DIR}/events/events-hub.html`, + fgPath: `${FG_DIR}/events/events-hub.html`, + }, + }, + { + tcid: 'C20', + name: '@fg-copy-integrity-json', + tags: '@regression @floodgate @nopr', + desc: 'Copy JSON file — byte-identical in FG (no URL rewrite for JSON)', + data: { + paths: [SIMPLE.sheetJson], + sourcePath: `${TEST_DIR}/test3-sheet.json`, + fgPath: `${FG_DIR}/test3-sheet.json`, + }, + }, + + // ============================================================ + // Suite D: Promote Workflow + // ============================================================ + { + tcid: 'D1', + name: '@fg-promote-single', + tags: '@smoke @floodgate @nopr', + desc: 'Promote a single file from FG to source', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'D2', + name: '@fg-promote-with-fragments', + tags: '@regression @floodgate @nopr', + desc: 'Promote page with fragment references', + data: { paths: [SIMPLE.withFragments] }, + }, + { + tcid: 'D3', + name: '@fg-promote-json', + tags: '@regression @floodgate @nopr', + desc: 'Promote JSON sheet file', + data: { paths: [SIMPLE.sheetJson] }, + }, + { + tcid: 'D4', + name: '@fg-promote-publish-toggle', + tags: '@regression @floodgate @nopr', + desc: 'Publish after promote toggle adds Publish step', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'D5', + name: '@fg-promote-ignore', + tags: '@regression @floodgate @nopr', + desc: 'Promote ignore paths excluded from promote', + data: { + paths: [`${TEST_DIR}/*`], + ignorePaths: [SIMPLE.sheetJson], + }, + }, + { + tcid: 'D6', + name: '@fg-promote-real-event', + tags: '@regression @floodgate @nopr', + desc: 'Promote a real event page — verify content integrity after round-trip', + data: { paths: [EVENTS.summitLondon] }, + }, + { + tcid: 'D7', + name: '@fg-promote-caas', + tags: '@regression @floodgate @nopr', + desc: 'Promote Events Hub — verify CaaS encoded strings survive promote', + data: { + paths: [EVENTS.eventsHub], + expectedContentContains: 'caas#~~H4sIAA', + }, + }, + { + tcid: 'D8', + name: '@fg-promote-large-batch', + tags: '@regression @floodgate @nopr', + desc: 'Promote all 5 real event pages together', + data: { + paths: [`${TEST_DIR}/events/*`], + expectedMinFiles: 5, + }, + }, + + // ============================================================ + // Suite E: Delete Workflow + // ============================================================ + { + tcid: 'E1', + name: '@fg-delete-single', + tags: '@smoke @floodgate @nopr', + desc: 'Delete single file from FG repo (not source)', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'E2', + name: '@fg-delete-confirm-dialog', + tags: '@regression @floodgate @nopr', + desc: 'Delete shows confirmation dialog', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'E3', + name: '@fg-delete-cancel', + tags: '@regression @floodgate @nopr', + desc: 'Delete cancel aborts operation', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'E4', + name: '@fg-delete-wildcard', + tags: '@regression @floodgate @nopr', + desc: 'Delete wildcard path removes all FG content', + data: { paths: [`${TEST_DIR}/*`] }, + }, + { + tcid: 'E5', + name: '@fg-delete-source-preserved', + tags: '@regression @floodgate @nopr', + desc: 'Delete only affects FG repo — source file remains intact', + data: { + paths: [EVENTS.summitLondon], + sourcePathToVerify: `${TEST_DIR}/events/summit-london.html`, + }, + }, + + // ============================================================ + // Suite F: Cancel & Retry + // ============================================================ + { + tcid: 'F1', + name: '@fg-cancel-mid-run', + tags: '@regression @floodgate @nopr', + desc: 'Cancel during large copy operation', + data: { paths: [`${TEST_DIR}/events/*`] }, + }, + { + tcid: 'F2', + name: '@fg-start-over', + tags: '@regression @floodgate @nopr', + desc: 'Start Over resets all state', + data: { paths: [SIMPLE.singleBlock] }, + }, + { + tcid: 'F3', + name: '@fg-retry-errors', + tags: '@regression @floodgate @nopr', + desc: 'Retry Errors button retries failed files only', + }, + + // ============================================================ + // Suite G: Full E2E Smoke Chain + // ============================================================ + { + tcid: 'G1', + name: '@fg-e2e-chain-simple', + tags: '@smoke @floodgate @nopr', + desc: 'Simple file: Copy -> Verify -> Promote -> Verify -> Delete -> Verify', + data: { + paths: [SIMPLE.singleBlock], + sourcePath: `${TEST_DIR}/test1-single-block.html`, + fgPath: `${FG_DIR}/test1-single-block.html`, + }, + }, + { + tcid: 'G2', + name: '@fg-e2e-chain-real-event', + tags: '@smoke @floodgate @nopr', + desc: 'Real event page: Copy -> Verify -> Promote -> Verify -> Delete -> Verify', + data: { + paths: [EVENTS.summitLondon], + sourcePath: `${TEST_DIR}/events/summit-london.html`, + fgPath: `${FG_DIR}/events/summit-london.html`, + }, + }, + ], +}; diff --git a/nala/features/dafloodgate/floodgate.test.js b/nala/features/dafloodgate/floodgate.test.js new file mode 100644 index 00000000000..eab02fc6061 --- /dev/null +++ b/nala/features/dafloodgate/floodgate.test.js @@ -0,0 +1,619 @@ +/* eslint-disable no-loop-func */ +import { expect, test } from '@playwright/test'; +import { + features, testRef, testOrg, testRepo, fgColor, testDir, +} from './floodgate.spec.js'; +import FloodgatePage from './floodgate.page.js'; + +test.use({ storageState: './nala/utils/auth.json' }); + +let fg; + +// Helper: find feature by tcid +function f(tcid) { + return features.find((x) => x.tcid === tcid); +} + +// ============================================================ +// Suite A: Page Load & Auth +// ============================================================ + +test.describe('DA Floodgate - Page Load', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + }); + + test(`${f('A1').name}, ${f('A1').tags}`, async () => { + await test.step('Navigate to Floodgate tool', async () => { + await fg.navigate(testRef); + }); + await test.step('Verify page loaded', async () => { + await expect(fg.title).toBeVisible(); + await expect(fg.title).toContainText('Floodgate'); + }); + await test.step('Verify no auth errors', async () => { + await expect(fg.errorMessage).not.toBeVisible(); + }); + }); + + test(`${f('A2').name}, ${f('A2').tags}`, async () => { + await fg.navigate(testRef); + await test.step('Verify default is Copy', async () => { + await expect(fg.pathsTextarea).toBeVisible(); + expect(await fg.actionSelect.inputValue()).toBe('fgCopy'); + }); + }); + + test(`${f('A3').name}, ${f('A3').tags}`, async () => { + await fg.navigate(testRef); + await fg.selectOperation('fgPromote'); + expect(await fg.actionSelect.inputValue()).toBe('fgPromote'); + await expect(fg.pathsTextarea).toBeVisible(); + }); + + test(`${f('A4').name}, ${f('A4').tags}`, async () => { + await fg.navigate(testRef); + await fg.selectOperation('fgDelete'); + expect(await fg.actionSelect.inputValue()).toBe('fgDelete'); + await expect(fg.pathsTextarea).toBeVisible(); + }); +}); + +// ============================================================ +// Suite B: Path Validation +// ============================================================ + +test.describe('DA Floodgate - Path Validation', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + await fg.navigate(testRef); + }); + + test(`${f('B1').name}, ${f('B1').tags}`, async () => { + const { data } = f('B1'); + await fg.enterPaths(data.paths); + expect(await fg.isStartEnabled()).toBe(true); + await expect(fg.pathCount).toContainText(`${data.paths.length}`); + }); + + test(`${f('B2').name}, ${f('B2').tags}`, async () => { + const { data } = f('B2'); + await fg.enterPaths(data.paths); + expect(await fg.isStartEnabled()).toBe(false); + }); + + test(`${f('B3').name}, ${f('B3').tags}`, async () => { + const { data } = f('B3'); + await fg.enterPaths(data.paths); + expect(await fg.isStartEnabled()).toBe(false); + }); + + test(`${f('B4').name}, ${f('B4').tags}`, async () => { + const { data } = f('B4'); + await fg.enterPaths(data.paths); + await expect(fg.invalidPathsHint).toBeVisible(); + }); + + test(`${f('B5').name}, ${f('B5').tags}`, async () => { + const { data } = f('B5'); + await fg.enterPaths([data.aemUrl]); + expect(await fg.isStartEnabled()).toBe(true); + await expect(fg.repoInfo).toBeVisible(); + }); + + test(`${f('B6').name}, ${f('B6').tags}`, async () => { + const { data } = f('B6'); + await fg.enterPaths(data.paths); + expect(await fg.isStartEnabled()).toBe(true); + }); + + test(`${f('B7').name}, ${f('B7').tags}`, async () => { + const { data } = f('B7'); + await fg.enterPaths(data.paths); + await expect(fg.repoInfo).toBeVisible(); + await expect(fg.sourceRepo).toContainText(testRepo); + await expect(fg.fgRepo).toContainText(`${testRepo}-fg-${fgColor}`); + }); + + test(`${f('B8').name}, ${f('B8').tags}`, async () => { + const { data } = f('B8'); + await fg.enterPaths(data.paths); + await fg.page.reload(); + await fg.page.waitForLoadState('domcontentloaded'); + await fg.dismissPopup(); + await fg.initFrame(); + const value = await fg.pathsTextarea.inputValue(); + expect(value).toContain('nala-fg-test'); + }); + + test(`${f('B9').name}, ${f('B9').tags}`, async () => { + const { data } = f('B9'); + await fg.enterPaths(data.paths); + await fg.clickClear(); + expect(await fg.pathsTextarea.inputValue()).toBe(''); + expect(await fg.isStartEnabled()).toBe(false); + }); +}); + +// ============================================================ +// Suite C: Copy Workflow — Simple Content +// ============================================================ + +test.describe('DA Floodgate - Copy (Simple Content)', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + test.setTimeout(120 * 1000); + await fg.navigate(testRef); + }); + + // C1-C7: simple content copies (parametrized) + for (const tcid of ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7']) { + const feat = f(tcid); + test(`${feat.name}, ${feat.tags}`, async () => { + const { data } = feat; + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + await expect(fg.doneStep).toBeVisible(); + }); + } + + test(`${f('C8').name}, ${f('C8').tags}`, async () => { + const { data } = f('C8'); + await fg.enterPaths(data.paths); + await fg.togglePreviewAfterCopy(true); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + const previewTab = fg.tabNav.locator('[data-target="preview"]'); + await expect(previewTab).toBeVisible(); + }); + + test(`${f('C9').name}, ${f('C9').tags}`, async () => { + const { data } = f('C9'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + const fileCount = await fg.fileList.count(); + expect(fileCount).toBeGreaterThanOrEqual(data.expectedMinFiles); + }); + + test(`${f('C10').name}, ${f('C10').tags}`, async () => { + const { data } = f('C10'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + const initialCount = await fg.fileList.count(); + // Remove the first file from list (icon button inside li) + await fg.fileList.first().locator('button.icon-button').click(); + const newCount = await fg.fileList.count(); + expect(newCount).toBe(initialCount - 1); + }); +}); + +// ============================================================ +// Suite C+: Copy Workflow — Real Event Content +// ============================================================ + +test.describe('DA Floodgate - Copy (Real Events)', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + test.setTimeout(180 * 1000); + await fg.navigate(testRef); + }); + + test(`${f('C11').name}, ${f('C11').tags}`, async () => { + const { data } = f('C11'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + + await test.step('Verify fragment discovered in Find step', async () => { + // File list should have at least: 1 page + 1 fragment + const fileCount = await fg.fileList.count(); + expect(fileCount).toBeGreaterThanOrEqual(1 + data.expectedFragmentCount); + }); + + await fg.clickCopy(); + await fg.waitForDone(); + }); + + test(`${f('C12').name}, ${f('C12').tags}`, async () => { + const { data } = f('C12'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + + await test.step('Verify both local + shared fragments discovered', async () => { + const fileCount = await fg.fileList.count(); + // Expect: 1 page + 2 fragments (local venue-additional-info + shared stay-connected-blade) + expect(fileCount).toBeGreaterThanOrEqual(1 + data.expectedFragmentCount); + }); + + await fg.clickCopy(); + await fg.waitForDone(); + }); + + test(`${f('C13').name}, ${f('C13').tags}`, async () => { + const { data } = f('C13'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + // Large page should still succeed + const successCount = await fg.getSuccessCount(); + expect(successCount).toBeGreaterThanOrEqual(1); + }); + + test(`${f('C14').name}, ${f('C14').tags}`, async () => { + const { data } = f('C14'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + + await test.step('Verify CaaS encoded content preserved in FG repo', async () => { + // Fetch FG copy and confirm CaaS encoded string still present + const token = await fg.getDaToken(); + const fgContent = await fg.getFileContent(data.expectedFgPath, token); + expect(fgContent).toContain(data.expectedContentContains); + }); + }); + + test(`${f('C15').name}, ${f('C15').tags}`, async () => { + const { data } = f('C15'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + const successCount = await fg.getSuccessCount(); + expect(successCount).toBeGreaterThanOrEqual(data.expectedMinFiles); + }); + + test(`${f('C16').name}, ${f('C16').tags}`, async () => { + const { data } = f('C16'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + const fileCount = await fg.fileList.count(); + expect(fileCount).toBeGreaterThanOrEqual(data.expectedMinFiles); + }); +}); + +// ============================================================ +// Suite C: Content Integrity — source vs FG byte-compare +// ============================================================ + +test.describe('DA Floodgate - Content Integrity', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + test.setTimeout(180 * 1000); + await fg.navigate(testRef); + }); + + // C17-C20: copy -> compare source and FG content (with URL rewrite normalization) + for (const tcid of ['C17', 'C18', 'C19', 'C20']) { + const feat = f(tcid); + test(`${feat.name}, ${feat.tags}`, async () => { + const { data } = feat; + + await test.step('Copy file to FG via UI', async () => { + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + }); + + await test.step('Compare source vs FG content', async () => { + const result = await fg.compareSourceVsFg(data.sourcePath, data.fgPath, { + org: testOrg, + repo: testRepo, + color: fgColor, + }); + + if (!result.identical && result.diffLines) { + // eslint-disable-next-line no-console + console.log(`[${feat.tcid}] Content diff (first ${result.diffLines.length} lines):`); + for (const d of result.diffLines) { + // eslint-disable-next-line no-console + console.log(` L${d.line}:\n src: ${d.source}\n fg : ${d.fg}`); + } + } + + expect(result.identical, `Source and FG content should be identical after URL normalization. Source: ${result.sourceLength}b, FG: ${result.fgLength}b`).toBe(true); + }); + }); + } +}); + +// ============================================================ +// Suite D: Promote Workflow +// ============================================================ + +test.describe('DA Floodgate - Promote', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + test.setTimeout(180 * 1000); + await fg.navigate(testRef); + await fg.selectOperation('fgPromote'); + }); + + for (const tcid of ['D1', 'D2', 'D3']) { + const feat = f(tcid); + test(`${feat.name}, ${feat.tags}`, async () => { + const { data } = feat; + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickPromote(); + // Small file counts should not trigger confirmation dialog + try { + await fg.waitForDialog(3000); + await fg.confirmDialog(); + } catch { /* no dialog */ } + await fg.waitForDone(); + }); + } + + test(`${f('D4').name}, ${f('D4').tags}`, async () => { + const { data } = f('D4'); + await fg.enterPaths(data.paths); + await fg.togglePublishAfterPromote(true); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickPromote(); + try { await fg.waitForDialog(3000); await fg.confirmDialog(); } catch { /* noop */ } + await fg.waitForDone(); + const publishTab = fg.tabNav.locator('[data-target="publish"]'); + await expect(publishTab).toBeVisible(); + }); + + test(`${f('D5').name}, ${f('D5').tags}`, async () => { + test.skip(true, 'Requires promote-ignore config in /.milo/floodgate/config.json'); + }); + + test(`${f('D6').name}, ${f('D6').tags}`, async () => { + const { data } = f('D6'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickPromote(); + try { await fg.waitForDialog(3000); await fg.confirmDialog(); } catch { /* noop */ } + await fg.waitForDone(); + const successCount = await fg.getSuccessCount(); + expect(successCount).toBeGreaterThanOrEqual(1); + }); + + test(`${f('D7').name}, ${f('D7').tags}`, async () => { + const { data } = f('D7'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickPromote(); + try { await fg.waitForDialog(3000); await fg.confirmDialog(); } catch { /* noop */ } + await fg.waitForDone(); + + await test.step('Verify CaaS content still in source repo after promote', async () => { + const token = await fg.getDaToken(); + const sourceContent = await fg.getFileContent(`${testDir}/events/events-hub.html`, token); + expect(sourceContent).toContain(data.expectedContentContains); + }); + }); + + test(`${f('D8').name}, ${f('D8').tags}`, async () => { + const { data } = f('D8'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickPromote(); + try { await fg.waitForDialog(5000); await fg.confirmDialog(); } catch { /* noop */ } + await fg.waitForDone(); + const successCount = await fg.getSuccessCount(); + expect(successCount).toBeGreaterThanOrEqual(data.expectedMinFiles); + }); +}); + +// ============================================================ +// Suite E: Delete Workflow +// ============================================================ + +test.describe('DA Floodgate - Delete', () => { + test.beforeEach(async ({ page }, testInfo) => { + fg = new FloodgatePage(page); + test.setTimeout(180 * 1000); + await fg.navigate(testRef); + + // Ensure FG has the file(s) a delete test will try to delete. + // Earlier tests may have deleted them — self-heal by copying source -> FG via API. + const needsFile = /fg-delete-(confirm-dialog|cancel|single|source-preserved)/.test(testInfo.title); + if (needsFile) { + const token = await fg.getDaToken(); + // Determine which file is needed from test title + const isEventFile = testInfo.title.includes('source-preserved'); + const sourcePath = isEventFile + ? `${testDir}/events/summit-london.html` + : `${testDir}/test1-single-block.html`; + const fgPath = sourcePath.replace(`/${testOrg}/${testRepo}/`, `/${testOrg}/${testRepo}-fg-${fgColor}/`); + await fg.ensureFileInFg(sourcePath, fgPath, token); + } + + await fg.selectOperation('fgDelete'); + }); + + test(`${f('E1').name}, ${f('E1').tags}`, async () => { + const { data } = f('E1'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickDelete(); + await fg.waitForDialog(); + await fg.confirmDialog(); + await fg.waitForDone(); + }); + + test(`${f('E2').name}, ${f('E2').tags}`, async () => { + const { data } = f('E2'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickDelete(); + await fg.waitForDialog(); + await expect(fg.dialogBox).toBeVisible(); + await expect(fg.dialogMessage).toBeVisible(); + await fg.cancelDialog(); + }); + + test(`${f('E3').name}, ${f('E3').tags}`, async () => { + const { data } = f('E3'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickDelete(); + await fg.waitForDialog(); + await fg.cancelDialog(); + await expect(fg.dialogOverlay).not.toBeVisible(); + await expect(fg.doneStep).not.toBeVisible(); + }); + + test(`${f('E4').name}, ${f('E4').tags}`, async () => { + const { data } = f('E4'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickDelete(); + await fg.waitForDialog(); + await fg.confirmDialog(); + await fg.waitForDone(); + }); + + test(`${f('E5').name}, ${f('E5').tags}`, async () => { + const { data } = f('E5'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickDelete(); + await fg.waitForDialog(); + await fg.confirmDialog(); + await fg.waitForDone(); + + await test.step('Verify source file still exists (delete only affects FG)', async () => { + const token = await fg.getDaToken(); + const exists = await fg.checkFileExists(data.sourcePathToVerify, token); + expect(exists).toBe(true); + }); + }); +}); + +// ============================================================ +// Suite F: Cancel & Retry +// ============================================================ + +test.describe('DA Floodgate - Cancel & Retry', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + test.setTimeout(120 * 1000); + await fg.navigate(testRef); + }); + + test(`${f('F1').name}, ${f('F1').tags}`, async () => { + const { data } = f('F1'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + // Attempt to cancel as soon as Cancel button appears + try { + await fg.cancelButton.click({ timeout: 2000 }); + // Verify cancelled state + await expect(fg.component).toContainText(/cancel/i, { timeout: 10000 }); + } catch { + // Operation may have completed too fast; acceptable + test.skip(true, 'Operation completed before cancel button could be clicked'); + } + }); + + test(`${f('F2').name}, ${f('F2').tags}`, async () => { + const { data } = f('F2'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + await fg.clickStartOver(); + await expect(fg.pathsTextarea).toBeVisible(); + await expect(fg.doneStep).not.toBeVisible(); + }); + + test(`${f('F3').name}, ${f('F3').tags}`, async () => { + test.skip(true, 'Requires a deterministic way to produce errors for retry'); + }); +}); + +// ============================================================ +// Suite G: Full E2E Smoke Chain +// ============================================================ + +test.describe('DA Floodgate - E2E Smoke Chain', () => { + test.beforeEach(async ({ page }) => { + fg = new FloodgatePage(page); + test.setTimeout(300 * 1000); + }); + + async function runChain(data) { + // Step 1: Copy + await fg.navigate(testRef); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickCopy(); + await fg.waitForDone(); + + // Verify via API: file exists in FG repo + const token = await fg.getDaToken(); + expect(await fg.checkFileExists(data.fgPath, token)).toBe(true); + + // Step 2: Promote + await fg.clickStartOver(); + await fg.selectOperation('fgPromote'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickPromote(); + try { await fg.waitForDialog(3000); await fg.confirmDialog(); } catch { /* noop */ } + await fg.waitForDone(); + + // Verify via API: source file still exists + expect(await fg.checkFileExists(data.sourcePath, token)).toBe(true); + + // Step 3: Delete FG content + await fg.clickStartOver(); + await fg.selectOperation('fgDelete'); + await fg.enterPaths(data.paths); + await fg.clickFind(); + await fg.waitForFindComplete(); + await fg.clickDelete(); + await fg.waitForDialog(); + await fg.confirmDialog(); + await fg.waitForDone(); + + // Verify via API: FG file no longer exists + expect(await fg.checkFileExists(data.fgPath, token)).toBe(false); + // Source still intact + expect(await fg.checkFileExists(data.sourcePath, token)).toBe(true); + } + + test(`${f('G1').name}, ${f('G1').tags}`, async () => { + await runChain(f('G1').data); + }); + + test(`${f('G2').name}, ${f('G2').tags}`, async () => { + await runChain(f('G2').data); + }); +}); diff --git a/nala/features/dafloodgate/seed-real-content.js b/nala/features/dafloodgate/seed-real-content.js new file mode 100644 index 00000000000..b05f2825c7a --- /dev/null +++ b/nala/features/dafloodgate/seed-real-content.js @@ -0,0 +1,386 @@ +#!/usr/bin/env node + +/** + * Seed real event content into the Floodgate test sandbox. + * + * Downloads 5 real event pages from da-bacom / da-events repos, extracts their + * referenced fragments, rewrites URLs to point to the sandbox, and uploads + * everything to /adobecom/da-events/drafts/nala-fg-test/. + * + * Uses nala/utils/auth.json for authentication (run da-login.js first). + * + * Usage: + * node nala/features/dafloodgate/seed-real-content.js [seed|verify|cleanup] + */ + +/* eslint-disable no-console, no-await-in-loop, no-continue, no-cond-assign, + no-use-before-define, no-inner-declarations, default-param-last, no-unused-vars */ + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const AUTH_FILE = path.resolve(__dirname, '../../utils/auth.json'); +const DA_ADMIN = 'https://admin.da.live'; + +const TARGET_ORG = 'adobecom'; +const TARGET_REPO = 'da-events'; +// URL path (used in HTML href, no org/repo prefix since host implies them) +const SANDBOX_URL = '/drafts/nala-fg-test'; +// DA path (used for API upload, includes org/repo) +const SANDBOX = `/${TARGET_ORG}/${TARGET_REPO}${SANDBOX_URL}`; + +// Real event pages to seed. Each is given a short name and source location in DA. +// The script will download from {sourceOrg}/{sourceRepo}{sourcePath} and write +// to {SANDBOX}/events/{name}.html. +const PAGES = [ + { + name: 'summit-london', + sourceOrg: 'adobecom', + sourceRepo: 'da-bacom', + sourcePath: '/uk/resources/events/regional-summit-series/adobe-summit-london-2026/london/gb/2026-07-07', + eventDate: '2026-07-07', // date-folder fragments loaded at runtime by chrono-box + desc: 'UK Summit event (da-bacom) — standard event layout', + }, + { + name: 'summit-munich', + sourceOrg: 'adobecom', + sourceRepo: 'da-bacom', + sourcePath: '/de/resources/events/regional-summit-series/adobe-summit-munich-2026/munchen/by/de/2026-07-14', + eventDate: '2026-07-14', + desc: 'DE Summit event (da-bacom) — multi-locale', + }, + { + name: 'creative-cafe-ny', + sourceOrg: 'adobecom', + sourceRepo: 'da-events', + sourcePath: '/events/creative-cafe/adobe-creative-cafe-new-york/new-york/ny/us/2026-06-10', + eventDate: '2026-06-10', + desc: 'US Creative Cafe — has shared fragment reference', + }, + { + name: 'creator-live-london', + sourceOrg: 'adobecom', + sourceRepo: 'da-events', + sourcePath: '/uk/events/creator-live/creator-live-london/london/gb/2026-03-17', + eventDate: '2026-03-17', + desc: 'UK Creator Live — largest event page (~68KB)', + }, + { + name: 'events-hub', + sourceOrg: 'adobecom', + sourceRepo: 'da-events', + sourcePath: '/events', + // no eventDate — hub page, not a date-based event + desc: 'Events hub — contains CaaS dynamic blocks', + }, +]; + +// --------------------------------------------------------------------------- + +function loadAuth() { + if (!fs.existsSync(AUTH_FILE)) { + console.error(`ERROR: ${AUTH_FILE} not found. Run \`node nala/utils/da-login.js\` first.`); + process.exit(1); + } + return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')); +} + +async function getToken(page) { + await page.goto('https://da.live'); + await page.waitForLoadState('domcontentloaded'); + return page.evaluate(() => { + for (const key of Object.keys(localStorage)) { + if (key.startsWith('adobeid_ims_access_token')) { + try { return JSON.parse(localStorage.getItem(key)).tokenValue; } catch { /* noop */ } + } + } + return null; + }); +} + +function authHeaders(token) { + return { Authorization: `Bearer ${token}` }; +} + +async function daFetch(page, urlPath, method = 'GET', token, opts = {}) { + const url = urlPath.startsWith('http') ? urlPath : `${DA_ADMIN}${urlPath}`; + const resp = await page.request.fetch(url, { + method, + headers: { ...authHeaders(token), ...(opts.headers || {}) }, + data: opts.data, + multipart: opts.multipart, + }); + return resp; +} + +async function sourceGet(page, fullPath, token) { + const resp = await daFetch(page, `/source${fullPath}.html`, 'GET', token); + if (resp.status() !== 200) return null; + return resp.text(); +} + +async function sourceExists(page, fullPath, token) { + const resp = await daFetch(page, `/source${fullPath}`, 'HEAD', token); + return resp.status() === 200; +} + +async function uploadHtml(page, fullPath, html, token) { + // Editable files need a version created first + await daFetch(page, `/versionsource${fullPath}.html`, 'POST', token, { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify({ label: 'Seeded real content for Floodgate tests' }), + }); + + const resp = await daFetch(page, `/source${fullPath}.html`, 'POST', token, { + multipart: { + data: { + name: 'data', + mimeType: 'text/html', + buffer: Buffer.from(html, 'utf-8'), + }, + }, + }); + return resp.status(); +} + +async function deletePath(page, fullPath, token) { + const resp = await daFetch(page, `/source${fullPath}`, 'DELETE', token); + return resp.status(); +} + +async function listDir(page, fullPath, token) { + const resp = await daFetch(page, `/list${fullPath}`, 'GET', token); + if (resp.status() !== 200) return []; + return resp.json(); +} + +// --------------------------------------------------------------------------- + +/** + * Find internal fragment references in HTML content. + * Matches hrefs pointing to /{anything}/fragments/... in the same origin family. + */ +function extractFragmentPaths(html, sourceOrg, sourceRepo) { + const frags = new Set(); + const fragmentRegex = new RegExp( + `href="(?:https?://[^/]*--${sourceRepo}--${sourceOrg}\\.[^"/]+)?(/[^"]*?/fragments/[^"]+?)"`, + 'g', + ); + let m; + while ((m = fragmentRegex.exec(html)) !== null) { + // Clean: strip query/hash, remove .html suffix, strip trailing slash + const p = m[1].split('?')[0].split('#')[0].replace(/\.html$/, '').replace(/\/$/, ''); + frags.add(p); + } + return [...frags]; +} + +/** + * Rewrite URLs in HTML: + * 1. Replace host references {ref}--{sourceRepo}--{sourceOrg} with + * {ref}--{targetRepo}--{targetOrg} + * 2. Rewrite fragment paths from their original location to the sandbox location. + */ +function rewriteContent(html, sourceOrg, sourceRepo, fragmentMap) { + let out = html; + // Swap host refs (main--da-bacom--adobecom -> main--da-events--adobecom) + const hostRe = new RegExp(`(--)${sourceRepo}(--${sourceOrg}\\.)`, 'g'); + out = out.replace(hostRe, `$1${TARGET_REPO}$2`); + + // Rewrite fragment paths + for (const [originalPath, sandboxPath] of Object.entries(fragmentMap)) { + const esc = originalPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(esc, 'g'); + out = out.replace(re, sandboxPath); + } + return out; +} + +// URL path: used in HTML href (e.g., /drafts/nala-fg-test/_fragments/...) +function sandboxFragmentUrlPath(originalFragPath) { + // Strip leading org/repo: /adobecom/da-events/events/events-shared/fragments/... + // -> /events/events-shared/fragments/... + const parts = originalFragPath.split('/').filter(Boolean); + const stripped = parts.slice(2).join('/'); + return `${SANDBOX_URL}/_fragments/${stripped}`; +} + +// DA path: used for API upload (e.g., /adobecom/da-events/drafts/nala-fg-test/_fragments/...) +function sandboxFragmentDaPath(originalFragPath) { + return `/${TARGET_ORG}/${TARGET_REPO}${sandboxFragmentUrlPath(originalFragPath)}`; +} + +// --------------------------------------------------------------------------- + +async function seedOne(page, token, pageCfg, seenFragments, log) { + const src = `/${pageCfg.sourceOrg}/${pageCfg.sourceRepo}${pageCfg.sourcePath}`; + log(`\n>> ${pageCfg.name}`); + log(` source: ${src}.html`); + + const html = await sourceGet(page, src, token); + if (!html) { + log(' ! failed to fetch source'); + return; + } + + // Discover referenced fragments + const fragPaths = extractFragmentPaths(html, pageCfg.sourceOrg, pageCfg.sourceRepo); + log(` fragments: ${fragPaths.length}`); + + // Fetch each fragment and compute both URL path (for HTML) and DA path (for upload) + const fragmentMap = {}; + for (const fragPath of fragPaths) { + const fullFragPath = fragPath.startsWith(`/${pageCfg.sourceOrg}/${pageCfg.sourceRepo}`) + ? fragPath + : `/${pageCfg.sourceOrg}/${pageCfg.sourceRepo}${fragPath}`; + + const urlPath = sandboxFragmentUrlPath(fullFragPath); + const daPath = sandboxFragmentDaPath(fullFragPath); + + // Record for URL rewrite — map ALL possible original forms to the URL path + fragmentMap[fragPath] = urlPath; + fragmentMap[fullFragPath] = urlPath; + + if (seenFragments.has(daPath)) continue; + seenFragments.add(daPath); + + const fragHtml = await sourceGet(page, fullFragPath, token); + if (!fragHtml) { + log(` ! fragment missing: ${fullFragPath}`); + continue; + } + // Fragment content: only rewrite host (fragments rarely reference other fragments) + const rewrittenFrag = rewriteContent(fragHtml, pageCfg.sourceOrg, pageCfg.sourceRepo, {}); + const status = await uploadHtml(page, daPath, rewrittenFrag, token); + log(` frag [${status}]: ${urlPath}`); + } + + // Rewrite main page HTML (uses URL paths) and upload to DA path + const destDaPath = `${SANDBOX}/events/${pageCfg.name}`; + const rewritten = rewriteContent(html, pageCfg.sourceOrg, pageCfg.sourceRepo, fragmentMap); + const status = await uploadHtml(page, destDaPath, rewritten, token); + log(` page [${status}]: ${destDaPath}.html`); + + // Seed date-based runtime fragments (loaded dynamically by chrono-box block) + // Source: {sourcePath}/fragments/{eventDate}/* + // Dest : {SANDBOX}/events/fragments/{eventDate}/* + if (pageCfg.eventDate) { + await seedDateFragments(page, token, pageCfg, log); + } +} + +async function seedDateFragments(page, token, pageCfg, log) { + const srcDir = `/${pageCfg.sourceOrg}/${pageCfg.sourceRepo}${pageCfg.sourcePath.replace(/\/[^/]*$/, '')}/fragments/${pageCfg.eventDate}`; + const destDir = `${SANDBOX}/events/fragments/${pageCfg.eventDate}`; + + const items = await listDir(page, srcDir, token); + if (items.length === 0) { + log(` (no date fragments at ${srcDir})`); + return; + } + + log(` date fragments (${items.length}) from ${pageCfg.eventDate}/`); + for (const item of items) { + if (!item.ext || item.ext !== 'html') continue; + const srcPath = item.path.replace(/\.html$/, ''); + const destPath = `${destDir}/${item.name}`; + + const fragHtml = await sourceGet(page, srcPath, token); + if (!fragHtml) { + log(` ! failed to fetch ${srcPath}`); + continue; + } + // Rewrite host references (handles da-bacom -> da-events for Summit pages) + const rewritten = rewriteContent(fragHtml, pageCfg.sourceOrg, pageCfg.sourceRepo, {}); + const status = await uploadHtml(page, destPath, rewritten, token); + log(` [${status}] ${item.name}.html`); + } +} + +async function seed(page, token) { + console.log('\n=== Seeding real event content into sandbox ==='); + console.log(` target: ${SANDBOX}/events/`); + + const seenFragments = new Set(); + for (const cfg of PAGES) { + await seedOne(page, token, cfg, seenFragments, console.log); + } + console.log('\n=== Done ===\n'); +} + +async function verify(page, token) { + console.log('\n=== Sandbox contents ===\n'); + + async function recurse(dir, depth = 0) { + const items = await listDir(page, dir, token); + for (const item of items) { + const pad = ' '.repeat(depth); + if (item.ext) { + console.log(`${pad}${item.name}.${item.ext}`); + } else { + console.log(`${pad}${item.name}/`); + await recurse(item.path, depth + 1); + } + } + } + + await recurse(SANDBOX); + console.log(''); +} + +async function cleanup(page, token) { + console.log('\n=== Cleaning up seeded content ===\n'); + // Only delete the /events/ and /_fragments/ subdirs we created + // (events/ now contains both event pages and events/fragments/{date}/ subdirs) + const toClean = [`${SANDBOX}/events`, `${SANDBOX}/_fragments`]; + for (const dir of toClean) { + async function recurse(p) { + const items = await listDir(page, p, token); + for (const it of items) { + if (it.ext) { + const status = await deletePath(page, it.path, token); + console.log(` [${status}] ${it.path}`); + } else { + await recurse(it.path); + } + } + } + await recurse(dir); + } + console.log('\nNote: original test data (test1-*, test2-*, etc.) preserved.\n'); +} + +// --------------------------------------------------------------------------- + +(async () => { + const cmd = process.argv[2] || 'seed'; + if (!['seed', 'verify', 'cleanup'].includes(cmd)) { + console.error(`Unknown command: ${cmd}`); + console.error('Usage: node seed-real-content.js [seed|verify|cleanup]'); + process.exit(1); + } + + const storageState = loadAuth(); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ storageState }); + const page = await context.newPage(); + + try { + const token = await getToken(page); + if (!token) { + console.error('ERROR: failed to extract DA access token. Re-run da-login.js.'); + process.exit(1); + } + + if (cmd === 'seed') await seed(page, token); + else if (cmd === 'verify') await verify(page, token); + else if (cmd === 'cleanup') await cleanup(page, token); + } catch (err) { + console.error('FATAL:', err.message); + console.error(err.stack); + process.exit(1); + } finally { + await browser.close(); + } +})(); diff --git a/nala/features/dafloodgate/setup-test-data.js b/nala/features/dafloodgate/setup-test-data.js new file mode 100644 index 00000000000..aa81a14fea6 --- /dev/null +++ b/nala/features/dafloodgate/setup-test-data.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +/** + * Floodgate E2E Test Data — Inventory & Status + * + * The test sandbox at /adobecom/da-events/drafts/nala-fg-test/ is populated + * via two sources: + * 1. Simple test files — authored manually (test1-*, test2-*, etc.) + * 2. Real event pages — seeded by seed-real-content.js + * + * This script verifies what's present and prints a summary. + * + * Usage: + * node nala/features/dafloodgate/setup-test-data.js # verify + * node nala/features/dafloodgate/setup-test-data.js verify # same + * + * For creating/removing content, use: + * node nala/features/dafloodgate/seed-real-content.js seed # add real event pages + * node nala/features/dafloodgate/seed-real-content.js cleanup # remove seeded content + */ + +/* eslint-disable no-console, no-await-in-loop */ + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const AUTH_FILE = path.resolve(__dirname, '../../utils/auth.json'); +const DA_ADMIN = 'https://admin.da.live'; + +const TEST_ORG = process.env.FG_ORG || 'adobecom'; +const TEST_REPO = process.env.FG_REPO || 'da-events'; +const FG_COLOR = process.env.FG_COLOR || 'pink'; + +const TEST_DIR = `/${TEST_ORG}/${TEST_REPO}/drafts/nala-fg-test`; +const FG_DIR = `/${TEST_ORG}/${TEST_REPO}-fg-${FG_COLOR}/drafts/nala-fg-test`; + +// Tier-1 simple files (should always be present) +const SIMPLE_FILES = [ + `${TEST_DIR}/test1-single-block.html`, + `${TEST_DIR}/test2-multiple-blocks.html`, + `${TEST_DIR}/test3-sheet.json`, + `${TEST_DIR}/test4-with-fragments.html`, + `${TEST_DIR}/2024-11-14.html`, + `${TEST_DIR}/fragments/test-fragment-1.html`, + `${TEST_DIR}/assets/001.png`, + `${TEST_DIR}/test1.png`, + `${TEST_DIR}/test2.png`, + `${TEST_DIR}/google.link`, +]; + +// Tier-2 seeded real event files (created by seed-real-content.js) +const EVENT_FILES = [ + `${TEST_DIR}/events/summit-london.html`, + `${TEST_DIR}/events/summit-munich.html`, + `${TEST_DIR}/events/creative-cafe-ny.html`, + `${TEST_DIR}/events/creator-live-london.html`, + `${TEST_DIR}/events/events-hub.html`, +]; + +function loadAuth() { + if (!fs.existsSync(AUTH_FILE)) { + console.error(`ERROR: ${AUTH_FILE} not found.`); + console.error('Run `node nala/utils/da-login.js` first.'); + process.exit(1); + } + return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')); +} + +async function getToken(page) { + await page.goto('https://da.live'); + await page.waitForLoadState('domcontentloaded'); + return page.evaluate(() => { + for (const key of Object.keys(localStorage)) { + if (key.startsWith('adobeid_ims_access_token')) { + try { return JSON.parse(localStorage.getItem(key)).tokenValue; } catch { /* noop */ } + } + } + return null; + }); +} + +async function fileExists(page, filePath, token) { + const resp = await page.request.head(`${DA_ADMIN}/source${filePath}`, { headers: { Authorization: `Bearer ${token}` } }); + return resp.status() === 200; +} + +async function verify(page, token) { + console.log('\n=== Floodgate Test Data Inventory ==='); + console.log(` Source sandbox: ${TEST_DIR}`); + console.log(` FG sandbox : ${FG_DIR}\n`); + + console.log('--- Tier 1: Simple test files (manually authored) ---'); + let simpleOk = 0; + for (const f of SIMPLE_FILES) { + const exists = await fileExists(page, f, token); + console.log(` ${exists ? '[OK] ' : '[MISS]'} ${f}`); + if (exists) simpleOk += 1; + } + console.log(` -> ${simpleOk}/${SIMPLE_FILES.length} present`); + + console.log('\n--- Tier 2: Real event content (seeded) ---'); + let eventOk = 0; + for (const f of EVENT_FILES) { + const exists = await fileExists(page, f, token); + console.log(` ${exists ? '[OK] ' : '[MISS]'} ${f}`); + if (exists) eventOk += 1; + } + console.log(` -> ${eventOk}/${EVENT_FILES.length} present`); + + console.log('\n--- FG repo (copies from prior tests) ---'); + const fgExists = await fileExists(page, `${FG_DIR}/test1-single-block.html`, token); + console.log(` ${fgExists ? '[has content]' : '[empty]'} ${FG_DIR}`); + + console.log('\n=== Summary ==='); + if (simpleOk < SIMPLE_FILES.length) { + console.log(' Some simple test files missing. Create manually via da.live UI.'); + } + if (eventOk < EVENT_FILES.length) { + console.log(' Seeded content missing. Run:'); + console.log(' node nala/features/dafloodgate/seed-real-content.js seed'); + } + if (simpleOk === SIMPLE_FILES.length && eventOk === EVENT_FILES.length) { + console.log(' All test data present. Ready to run tests.'); + } + console.log(''); +} + +(async () => { + const cmd = process.argv[2] || 'verify'; + if (!['verify'].includes(cmd)) { + console.error(`Unsupported command: ${cmd}`); + console.error('This script only supports "verify". For seeding/cleanup, use seed-real-content.js'); + process.exit(1); + } + + const storageState = loadAuth(); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ storageState }); + const page = await context.newPage(); + + try { + const token = await getToken(page); + if (!token) { + console.error('ERROR: failed to extract DA access token. Re-run da-login.js.'); + process.exit(1); + } + await verify(page, token); + } catch (err) { + console.error('FATAL:', err.message); + process.exit(1); + } finally { + await browser.close(); + } +})(); diff --git a/nala/utils/da-login.js b/nala/utils/da-login.js new file mode 100644 index 00000000000..4e69b067740 --- /dev/null +++ b/nala/utils/da-login.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Shared DA Live login utility. + * Opens a browser to da.live, waits for the user to complete IMS login, + * and saves the session (cookies + localStorage) so Playwright tests + * can reuse it via storageState. + * + * Usage: + * node nala/utils/da-login.js # default: https://da.live + * npm run nala:login # same, via npm script + * + * Output: + * nala/utils/auth.json (consumed by test.use({ storageState })) + */ + +const { chromium } = require('playwright'); +const path = require('path'); + +const AUTH_FILE = path.join(__dirname, 'auth.json'); +const DA_LIVE_URL = 'https://da.live'; + +(async () => { + console.log('\n--- DA Live Login ---\n'); + console.log('1. A Chrome browser will open to da.live'); + console.log('2. Click the Login button and complete Adobe IMS auth (Skyline Org)'); + console.log('3. Once you see the DA Live dashboard, come back here'); + console.log('4. Press ENTER to save the session\n'); + + const browser = await chromium.launch({ headless: false }); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(DA_LIVE_URL); + await page.waitForLoadState('domcontentloaded'); + + await new Promise((resolve) => { + process.stdout.write('Press ENTER after login is complete... '); + process.stdin.once('data', resolve); + }); + + await context.storageState({ path: AUTH_FILE }); + console.log(`\nAuth state saved to: ${AUTH_FILE}`); + console.log('Tests will automatically pick this up. You can now run:\n'); + console.log(' LPB_REF=stage npm run nala local @lpb mode=headed\n'); + + await browser.close(); + process.exit(0); +})(); diff --git a/playwright.config.js b/playwright.config.js index 0648742f7b7..79edea8030c 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -24,6 +24,7 @@ const miloIgnore = isCI 'features/commerce/**', 'features/promotions/**', 'features/osttools/**', + 'features/dafloodgate/**', ] : []; // In local runs → allow @mas annotations to work diff --git a/test/blocks/preflight/checks/merch.test.js b/test/blocks/preflight/checks/merch.test.js new file mode 100644 index 00000000000..17603c0c5da --- /dev/null +++ b/test/blocks/preflight/checks/merch.test.js @@ -0,0 +1,104 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { + findFragmentElements, + checkFragmentPublished, + checkUnpublishedFragments, + runChecks, +} from '../../../../libs/blocks/preflight/checks/merch.js'; +import { SEVERITY } from '../../../../libs/blocks/preflight/checks/constants.js'; + +const UUID_PUBLISHED = '11111111-1111-1111-1111-111111111111'; +const UUID_UNPUBLISHED = '22222222-2222-2222-2222-222222222222'; + +function buildArea() { + const area = document.createElement('div'); + area.innerHTML = ` + + + + + + + `; + return area; +} + +function stubFetch() { + return sinon.stub(window, 'fetch').callsFake((url) => { + const id = new URL(url).searchParams.get('id'); + if (id === UUID_PUBLISHED) return Promise.resolve({ status: 200 }); + if (id === UUID_UNPUBLISHED) return Promise.resolve({ status: 404 }); + return Promise.resolve({ status: 500 }); + }); +} + +describe('Preflight M@S Unpublished Fragments check', () => { + let area; + let fetchStub; + + beforeEach(() => { + area = buildArea(); + fetchStub = stubFetch(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('findFragmentElements returns one entry per aem-fragment with closest merch-card', () => { + const entries = findFragmentElements(area); + expect(entries).to.have.length(2); + expect(entries[0].uuid).to.equal(UUID_PUBLISHED); + expect(entries[0].card.tagName).to.equal('MERCH-CARD'); + expect(entries[1].uuid).to.equal(UUID_UNPUBLISHED); + expect(entries[1].card.tagName).to.equal('MERCH-CARD'); + }); + + it('checkFragmentPublished returns published=true on 200', async () => { + const result = await checkFragmentPublished(UUID_PUBLISHED, 'en_US'); + expect(result).to.deep.equal({ uuid: UUID_PUBLISHED, httpStatus: 200, published: true }); + }); + + it('checkFragmentPublished returns published=false on 404', async () => { + const result = await checkFragmentPublished(UUID_UNPUBLISHED, 'en_US'); + expect(result).to.deep.equal({ uuid: UUID_UNPUBLISHED, httpStatus: 404, published: false }); + }); + + it('checkUnpublishedFragments reports 1 unpublished out of 2 scanned', async () => { + const result = await checkUnpublishedFragments({ area, locale: 'en_US' }); + expect(result.scanned).to.equal(2); + expect(result.unpublished).to.have.length(1); + expect(result.unpublished[0].uuid).to.equal(UUID_UNPUBLISHED); + expect(result.unpublished[0].httpStatus).to.equal(404); + }); + + it('runChecks resolves to fail with CRITICAL severity when there are unpublished fragments', async () => { + const [promise] = runChecks({ area, locale: 'en_US', delayMs: 0 }); + const result = await promise; + expect(result.status).to.equal('fail'); + expect(result.severity).to.equal(SEVERITY.CRITICAL); + expect(result.details.unpublished).to.have.length(1); + }); + + it('runChecks resolves to pass when all fragments are published', async () => { + const cleanArea = document.createElement('div'); + cleanArea.innerHTML = ` + + + + `; + const [promise] = runChecks({ area: cleanArea, locale: 'en_US', delayMs: 0 }); + const result = await promise; + expect(result.status).to.equal('pass'); + expect(result.severity).to.equal(SEVERITY.CRITICAL); + }); + + it('builds the API URL with api_key and underscore locale', async () => { + await checkFragmentPublished(UUID_PUBLISHED, 'en_US'); + const url = fetchStub.firstCall.args[0]; + expect(url).to.include('api_key=wcms-commerce-ims-ro-user-milo'); + expect(url).to.include('locale=en_US'); + expect(url).to.include(`id=${UUID_PUBLISHED}`); + }); +});