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}
+
These fragments are referenced on this page but are not published. Publishing now will break the live page.
+ 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 = `
+