@brandlab-digital/conformance 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-e2e.d.ts","sourceRoot":"","sources":["../src/analytics-e2e.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAgB,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA2C,KAAK,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAQxG,MAAM,WAAW,4BAA4B;IAC3C,4EAA4E;IAC5E,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,sFAAsF;IACtF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,OAAO,CAAC,EAAE,sBAAsB,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC/D,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED,2GAA2G;AAC3G,eAAO,MAAM,iCAAiC,mBAE5C,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,mBAEpC,CAAC;AAKH,MAAM,MAAM,gBAAgB,GAAG,OAAO,EAAE,CAAC;AAEzC,kFAAkF;AAClF,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA+BrF;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EAAE,IAAI,CAAC,EAAE,MAAM;UAGvC,MAAM;YAAgC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACzG;AAED,gGAAgG;AAChG,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EACrC,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GACxF,MAAM,EAAE,CAiBV;AAED,wBAAgB,4BAA4B,CAAC,OAAO,GAAE,4BAAiC,QA4FtF"}
1
+ {"version":3,"file":"analytics-e2e.d.ts","sourceRoot":"","sources":["../src/analytics-e2e.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAgB,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA2C,KAAK,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAQxG,MAAM,WAAW,4BAA4B;IAC3C,4EAA4E;IAC5E,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,sFAAsF;IACtF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,OAAO,CAAC,EAAE,sBAAsB,GAAG;QAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC/D,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED,2GAA2G;AAC3G,eAAO,MAAM,iCAAiC,mBAE5C,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,mBAEpC,CAAC;AAKH,MAAM,MAAM,gBAAgB,GAAG,OAAO,EAAE,CAAC;AAEzC,kFAAkF;AAClF,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA+BrF;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EAAE,IAAI,CAAC,EAAE,MAAM;UAGvC,MAAM;YAAgC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACzG;AAED,gGAAgG;AAChG,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EACrC,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GACxF,MAAM,EAAE,CAiBV;AAED,wBAAgB,4BAA4B,CAAC,OAAO,GAAE,4BAAiC,QAiGtF"}
@@ -111,7 +111,9 @@ export function defineBrandlabAnalyticsTests(options = {}) {
111
111
  test('renders exactly one Google tag on every page', async ({ page }) => {
112
112
  for (const path of paths) {
113
113
  commands.length = 0;
114
- await page.goto(path, { waitUntil: 'load' });
114
+ // DOMContentLoaded, not load: next start's image optimiser can stall concurrent first image requests
115
+ // (vercel/next.js#98168), and nothing here needs the images. The expectations below wait for the tag.
116
+ await page.goto(path, { waitUntil: 'domcontentloaded' });
115
117
  const loaders = page.locator('script[src*="googletagmanager.com/gtag/js"]');
116
118
  await expect(loaders.first(), `${path}: no Google tag rendered (build the e2e target with a test NEXT_PUBLIC_GA_MEASUREMENT_ID)`)
117
119
  .toBeAttached({ timeout: 15_000 });
@@ -122,7 +124,7 @@ export function defineBrandlabAnalyticsTests(options = {}) {
122
124
  }
123
125
  });
124
126
  test('sends one phone_link_click per tap', async ({ page }) => {
125
- await page.goto(paths[0], { waitUntil: 'load' });
127
+ await page.goto(paths[0], { waitUntil: 'domcontentloaded' });
126
128
  const link = options.phoneLink ? page.locator(options.phoneLink).first() : page.locator('a[href^="tel:"]:visible').first();
127
129
  await expect(link, 'the page renders a telephone link').toBeVisible();
128
130
  await expect.poll(() => commands.some((command) => command[0] === 'js')).toBe(true);
@@ -171,7 +173,10 @@ export function defineBrandlabAnalyticsTests(options = {}) {
171
173
  }
172
174
  test('sends no retired or web_vital events', async ({ page }) => {
173
175
  for (const path of paths) {
174
- await page.goto(path, { waitUntil: 'load' });
176
+ const tagRuns = commands.filter((command) => command[0] === 'js').length;
177
+ await page.goto(path, { waitUntil: 'domcontentloaded' });
178
+ // The page's instrumentation has run once its Google tag has: any reporter would be listening by then.
179
+ await expect.poll(() => commands.filter((command) => command[0] === 'js').length, `${path}: the Google tag runs`).toBeGreaterThan(tagRuns);
175
180
  await page.mouse.wheel(0, 1_200);
176
181
  await page.waitForTimeout(300);
177
182
  // Web-vitals reporters flush when the page is hidden.
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-e2e.js","sourceRoot":"","sources":["../src/analytics-e2e.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared analytics journeys for a Brandlab production build. Google is\n * never contacted: the tag loader is stubbed and every dataLayer command is\n * recorded in the test process, across navigations.\n *\n * // tests/e2e/analytics.spec.ts\n * import { defineBrandlabAnalyticsTests } from '@brandlab-digital/conformance/analytics-e2e'\n *\n * defineBrandlabAnalyticsTests({\n * paths: ['/', '/services/blocked-drains'],\n * contact: { path: '/contact', fields: { ... }, submit: 'Send enquiry' },\n * })\n *\n * Build the e2e target with a test measurement ID (NEXT_PUBLIC_GA_MEASUREMENT_ID)\n * so BrandlabGoogleTag renders.\n */\nimport { expect, test, type Page } from '@playwright/test';\nimport { fillBrandlabContactForm, successPattern, type BrandlabContactJourney } from './contact-e2e.js';\nimport {\n ACCEPTED_CONTACT_RESULT,\n RETRYABLE_CONTACT_FAILURE,\n contactEndpointMatcher,\n isolateFromNetwork,\n} from './e2e-support.js';\n\nexport interface BrandlabAnalyticsTestOptions {\n /** Pages to load; the first renders a telephone link. Defaults to ['/']. */\n paths?: readonly string[];\n /** Selector of the telephone link to tap; defaults to the first visible tel: link. */\n phoneLink?: string;\n /** The contact journey whose accepted result emits one contact_form. */\n contact?: BrandlabContactJourney & { retry?: string | RegExp };\n /** Event names the project's analytics contract declares in additionalEvents. */\n additionalEvents?: readonly string[];\n}\n\n/** Event names that 3.0 no longer sends: legacy lead and phone events, GA4 form aliases and web vitals. */\nexport const BRANDLAB_RETIRED_ANALYTICS_EVENTS = Object.freeze([\n 'phone_call', 'phone_calls', 'contact_form_submission', 'form_start', 'form_submit', 'generate_lead', 'cta_click', 'web_vital',\n]);\n\n/**\n * The events the shared runtime sends; mirrors site-core's CANONICAL_LEAD_EVENTS,\n * CANONICAL_PHONE_INTERACTION_EVENTS and BRANDLAB_LEAD_DIAGNOSTIC_EVENTS. The\n * Google Ads `conversion` event is also allowed, only with an AW- destination.\n */\nexport const BRANDLAB_ANALYTICS_EVENTS = Object.freeze([\n 'contact_form', 'phone_link_click', 'lead_form_start', 'lead_form_error', 'lead_cta_click',\n]);\n\nconst PII_PARAMETER = /^(?:e_?mail|email_address|user_email|contact_email|phone|phone_number|mobile|mobile_number|user_phone|name|first_name|last_name|full_name|customer_name|contact_name|message|enquiry_message|form_message|address|street_address|residential_address|attachment|attachment_name|file_name|filename)$/i;\nconst PHONE_DEDUPE_WINDOW_MS = 1_200;\n\nexport type AnalyticsCommand = unknown[];\n\n/** Record every gtag/dataLayer command the page pushes, surviving navigations. */\nexport async function recordAnalyticsCommands(page: Page): Promise<AnalyticsCommand[]> {\n const commands: AnalyticsCommand[] = [];\n await page.exposeFunction('__brandlabRecordAnalytics', (command: AnalyticsCommand) => { commands.push(command); });\n await page.addInitScript(() => {\n const record = (entry: unknown) => {\n try {\n const command = entry && typeof entry === 'object' && 'length' in (entry as object)\n ? Array.from(entry as ArrayLike<unknown>)\n : [entry];\n void (window as unknown as { __brandlabRecordAnalytics: (value: unknown) => Promise<void> })\n .__brandlabRecordAnalytics(JSON.parse(JSON.stringify(command)));\n } catch { /* an unserialisable command is not analytics evidence */ }\n };\n const wrapped = new WeakSet<unknown[]>();\n const wrap = (layer: unknown[]) => {\n if (wrapped.has(layer)) return layer;\n wrapped.add(layer);\n const push = layer.push.bind(layer);\n layer.push = (...entries: unknown[]) => { entries.forEach(record); return push(...entries); };\n layer.forEach(record);\n return layer;\n };\n let dataLayer = wrap([]);\n Object.defineProperty(window, 'dataLayer', {\n configurable: true,\n get: () => dataLayer,\n // `window.dataLayer = window.dataLayer || []` reassigns the same array.\n set: (value: unknown[]) => { if (Array.isArray(value)) dataLayer = wrap(value); },\n });\n });\n return commands;\n}\n\nexport function analyticsEvents(commands: readonly AnalyticsCommand[], name?: string) {\n return commands\n .filter((command) => command[0] === 'event' && typeof command[1] === 'string' && (!name || command[1] === name))\n .map((command) => ({ name: command[1] as string, params: (command[2] ?? {}) as Record<string, unknown> }));\n}\n\n/** Problems a set of recorded commands shows: retired or unexpected events, or lead details. */\nexport function analyticsEventProblems(\n commands: readonly AnalyticsCommand[],\n options: { enteredValues?: readonly string[]; additionalEvents?: readonly string[] } = {},\n): string[] {\n const problems: string[] = [];\n const allowed = new Set([...BRANDLAB_ANALYTICS_EVENTS, ...(options.additionalEvents ?? [])]);\n for (const event of analyticsEvents(commands)) {\n if (BRANDLAB_RETIRED_ANALYTICS_EVENTS.includes(event.name)) problems.push(`retired event ${event.name}`);\n else if (event.name === 'conversion') {\n if (!String(event.params.send_to ?? '').startsWith('AW-')) problems.push('conversion event without a Google Ads destination');\n } else if (!allowed.has(event.name)) problems.push(`unexpected event ${event.name}`);\n for (const key of Object.keys(event.params)) {\n if (PII_PARAMETER.test(key)) problems.push(`${event.name} carries the ${key} parameter`);\n }\n }\n const serialised = JSON.stringify(commands).toLowerCase();\n for (const value of (options.enteredValues ?? []).map((entry) => entry.trim()).filter((entry) => entry.length >= 5)) {\n if (serialised.includes(value.toLowerCase())) problems.push(`an analytics command contains the entered value \"${value}\"`);\n }\n return problems;\n}\n\nexport function defineBrandlabAnalyticsTests(options: BrandlabAnalyticsTestOptions = {}) {\n const paths = options.paths?.length ? options.paths : ['/'];\n const additionalEvents = options.additionalEvents ?? [];\n\n test.describe('Brandlab analytics', () => {\n // Service workers could answer requests outside page and context routing.\n test.use({ serviceWorkers: 'block' });\n let commands: AnalyticsCommand[] = [];\n test.beforeEach(async ({ page, baseURL }) => {\n await isolateFromNetwork(page, { baseURL, googleTag: 'stub' });\n commands = await recordAnalyticsCommands(page);\n });\n\n test('renders exactly one Google tag on every page', async ({ page }) => {\n for (const path of paths) {\n commands.length = 0;\n await page.goto(path, { waitUntil: 'load' });\n const loaders = page.locator('script[src*=\"googletagmanager.com/gtag/js\"]');\n await expect(loaders.first(), `${path}: no Google tag rendered (build the e2e target with a test NEXT_PUBLIC_GA_MEASUREMENT_ID)`)\n .toBeAttached({ timeout: 15_000 });\n expect(await loaders.count(), `${path}: renders exactly one Google tag loader`).toBe(1);\n await expect.poll(() => commands.filter((command) => command[0] === 'js').length, `${path}: gtag('js') runs once`).toBe(1);\n const destinations = commands.filter((command) => command[0] === 'config').map((command) => String(command[1]));\n expect(new Set(destinations).size, `${path}: a destination is configured twice`).toBe(destinations.length);\n }\n });\n\n test('sends one phone_link_click per tap', async ({ page }) => {\n await page.goto(paths[0]!, { waitUntil: 'load' });\n const link = options.phoneLink ? page.locator(options.phoneLink).first() : page.locator('a[href^=\"tel:\"]:visible').first();\n await expect(link, 'the page renders a telephone link').toBeVisible();\n await expect.poll(() => commands.some((command) => command[0] === 'js')).toBe(true);\n // Keep the page in place; the shared listener still sees the tap.\n await page.evaluate(() => window.addEventListener('click', (event) => {\n if ((event.target as Element | null)?.closest?.('a[href^=\"tel:\"]')) event.preventDefault();\n }, true));\n await link.click();\n await expect.poll(() => analyticsEvents(commands, 'phone_link_click').length).toBe(1);\n await page.waitForTimeout(PHONE_DEDUPE_WINDOW_MS + 300);\n expect(analyticsEvents(commands, 'phone_link_click'), 'one tap sends one event').toHaveLength(1);\n await link.click();\n await expect.poll(() => analyticsEvents(commands, 'phone_link_click').length).toBe(2);\n for (const event of analyticsEvents(commands, 'phone_link_click')) {\n expect(event.params.placement, 'phone_link_click carries its placement').toBeTruthy();\n }\n expect(analyticsEventProblems(commands, { additionalEvents })).toEqual([]);\n });\n\n if (options.contact) {\n const journey = options.contact;\n test('sends one contact_form only after an accepted enquiry, without lead details', async ({ page }) => {\n let attempts = 0;\n await page.route(contactEndpointMatcher(journey.endpoint), async (route) => {\n attempts += 1;\n await route.fulfill(attempts === 1\n ? { status: 503, json: RETRYABLE_CONTACT_FAILURE }\n : { status: 200, json: ACCEPTED_CONTACT_RESULT });\n });\n await fillBrandlabContactForm(page, journey);\n const submit = page.locator('form').filter({ has: page.locator(`[name=\"${Object.keys(journey.fields)[0]}\"]`) })\n .first().getByRole('button', { name: journey.submit, exact: typeof journey.submit === 'string' });\n await submit.click();\n await expect.poll(() => attempts).toBe(1);\n await page.waitForTimeout(500);\n expect(analyticsEvents(commands, 'contact_form'), 'a failed delivery is not a conversion').toHaveLength(0);\n const retry = journey.retry ?? journey.submit;\n await page.locator('form').filter({ has: page.locator(`[name=\"${Object.keys(journey.fields)[0]}\"]`) })\n .first().getByRole('button', { name: retry, exact: typeof retry === 'string' }).click();\n await expect(page).toHaveURL(successPattern(journey.successPath));\n await expect.poll(() => analyticsEvents(commands, 'contact_form').length).toBe(1);\n await page.waitForTimeout(500);\n expect(analyticsEvents(commands, 'contact_form')).toHaveLength(1);\n expect(analyticsEventProblems(commands, { enteredValues: Object.values(journey.fields), additionalEvents })).toEqual([]);\n });\n }\n\n test('sends no retired or web_vital events', async ({ page }) => {\n for (const path of paths) {\n await page.goto(path, { waitUntil: 'load' });\n await page.mouse.wheel(0, 1_200);\n await page.waitForTimeout(300);\n // Web-vitals reporters flush when the page is hidden.\n await page.evaluate(() => {\n Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });\n document.dispatchEvent(new Event('visibilitychange'));\n window.dispatchEvent(new Event('pagehide'));\n });\n await page.waitForTimeout(300);\n }\n expect(analyticsEventProblems(commands, { additionalEvents })).toEqual([]);\n });\n });\n}\n"]}
1
+ {"version":3,"file":"analytics-e2e.js","sourceRoot":"","sources":["../src/analytics-e2e.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared analytics journeys for a Brandlab production build. Google is\n * never contacted: the tag loader is stubbed and every dataLayer command is\n * recorded in the test process, across navigations.\n *\n * // tests/e2e/analytics.spec.ts\n * import { defineBrandlabAnalyticsTests } from '@brandlab-digital/conformance/analytics-e2e'\n *\n * defineBrandlabAnalyticsTests({\n * paths: ['/', '/services/blocked-drains'],\n * contact: { path: '/contact', fields: { ... }, submit: 'Send enquiry' },\n * })\n *\n * Build the e2e target with a test measurement ID (NEXT_PUBLIC_GA_MEASUREMENT_ID)\n * so BrandlabGoogleTag renders.\n */\nimport { expect, test, type Page } from '@playwright/test';\nimport { fillBrandlabContactForm, successPattern, type BrandlabContactJourney } from './contact-e2e.js';\nimport {\n ACCEPTED_CONTACT_RESULT,\n RETRYABLE_CONTACT_FAILURE,\n contactEndpointMatcher,\n isolateFromNetwork,\n} from './e2e-support.js';\n\nexport interface BrandlabAnalyticsTestOptions {\n /** Pages to load; the first renders a telephone link. Defaults to ['/']. */\n paths?: readonly string[];\n /** Selector of the telephone link to tap; defaults to the first visible tel: link. */\n phoneLink?: string;\n /** The contact journey whose accepted result emits one contact_form. */\n contact?: BrandlabContactJourney & { retry?: string | RegExp };\n /** Event names the project's analytics contract declares in additionalEvents. */\n additionalEvents?: readonly string[];\n}\n\n/** Event names that 3.0 no longer sends: legacy lead and phone events, GA4 form aliases and web vitals. */\nexport const BRANDLAB_RETIRED_ANALYTICS_EVENTS = Object.freeze([\n 'phone_call', 'phone_calls', 'contact_form_submission', 'form_start', 'form_submit', 'generate_lead', 'cta_click', 'web_vital',\n]);\n\n/**\n * The events the shared runtime sends; mirrors site-core's CANONICAL_LEAD_EVENTS,\n * CANONICAL_PHONE_INTERACTION_EVENTS and BRANDLAB_LEAD_DIAGNOSTIC_EVENTS. The\n * Google Ads `conversion` event is also allowed, only with an AW- destination.\n */\nexport const BRANDLAB_ANALYTICS_EVENTS = Object.freeze([\n 'contact_form', 'phone_link_click', 'lead_form_start', 'lead_form_error', 'lead_cta_click',\n]);\n\nconst PII_PARAMETER = /^(?:e_?mail|email_address|user_email|contact_email|phone|phone_number|mobile|mobile_number|user_phone|name|first_name|last_name|full_name|customer_name|contact_name|message|enquiry_message|form_message|address|street_address|residential_address|attachment|attachment_name|file_name|filename)$/i;\nconst PHONE_DEDUPE_WINDOW_MS = 1_200;\n\nexport type AnalyticsCommand = unknown[];\n\n/** Record every gtag/dataLayer command the page pushes, surviving navigations. */\nexport async function recordAnalyticsCommands(page: Page): Promise<AnalyticsCommand[]> {\n const commands: AnalyticsCommand[] = [];\n await page.exposeFunction('__brandlabRecordAnalytics', (command: AnalyticsCommand) => { commands.push(command); });\n await page.addInitScript(() => {\n const record = (entry: unknown) => {\n try {\n const command = entry && typeof entry === 'object' && 'length' in (entry as object)\n ? Array.from(entry as ArrayLike<unknown>)\n : [entry];\n void (window as unknown as { __brandlabRecordAnalytics: (value: unknown) => Promise<void> })\n .__brandlabRecordAnalytics(JSON.parse(JSON.stringify(command)));\n } catch { /* an unserialisable command is not analytics evidence */ }\n };\n const wrapped = new WeakSet<unknown[]>();\n const wrap = (layer: unknown[]) => {\n if (wrapped.has(layer)) return layer;\n wrapped.add(layer);\n const push = layer.push.bind(layer);\n layer.push = (...entries: unknown[]) => { entries.forEach(record); return push(...entries); };\n layer.forEach(record);\n return layer;\n };\n let dataLayer = wrap([]);\n Object.defineProperty(window, 'dataLayer', {\n configurable: true,\n get: () => dataLayer,\n // `window.dataLayer = window.dataLayer || []` reassigns the same array.\n set: (value: unknown[]) => { if (Array.isArray(value)) dataLayer = wrap(value); },\n });\n });\n return commands;\n}\n\nexport function analyticsEvents(commands: readonly AnalyticsCommand[], name?: string) {\n return commands\n .filter((command) => command[0] === 'event' && typeof command[1] === 'string' && (!name || command[1] === name))\n .map((command) => ({ name: command[1] as string, params: (command[2] ?? {}) as Record<string, unknown> }));\n}\n\n/** Problems a set of recorded commands shows: retired or unexpected events, or lead details. */\nexport function analyticsEventProblems(\n commands: readonly AnalyticsCommand[],\n options: { enteredValues?: readonly string[]; additionalEvents?: readonly string[] } = {},\n): string[] {\n const problems: string[] = [];\n const allowed = new Set([...BRANDLAB_ANALYTICS_EVENTS, ...(options.additionalEvents ?? [])]);\n for (const event of analyticsEvents(commands)) {\n if (BRANDLAB_RETIRED_ANALYTICS_EVENTS.includes(event.name)) problems.push(`retired event ${event.name}`);\n else if (event.name === 'conversion') {\n if (!String(event.params.send_to ?? '').startsWith('AW-')) problems.push('conversion event without a Google Ads destination');\n } else if (!allowed.has(event.name)) problems.push(`unexpected event ${event.name}`);\n for (const key of Object.keys(event.params)) {\n if (PII_PARAMETER.test(key)) problems.push(`${event.name} carries the ${key} parameter`);\n }\n }\n const serialised = JSON.stringify(commands).toLowerCase();\n for (const value of (options.enteredValues ?? []).map((entry) => entry.trim()).filter((entry) => entry.length >= 5)) {\n if (serialised.includes(value.toLowerCase())) problems.push(`an analytics command contains the entered value \"${value}\"`);\n }\n return problems;\n}\n\nexport function defineBrandlabAnalyticsTests(options: BrandlabAnalyticsTestOptions = {}) {\n const paths = options.paths?.length ? options.paths : ['/'];\n const additionalEvents = options.additionalEvents ?? [];\n\n test.describe('Brandlab analytics', () => {\n // Service workers could answer requests outside page and context routing.\n test.use({ serviceWorkers: 'block' });\n let commands: AnalyticsCommand[] = [];\n test.beforeEach(async ({ page, baseURL }) => {\n await isolateFromNetwork(page, { baseURL, googleTag: 'stub' });\n commands = await recordAnalyticsCommands(page);\n });\n\n test('renders exactly one Google tag on every page', async ({ page }) => {\n for (const path of paths) {\n commands.length = 0;\n // DOMContentLoaded, not load: next start's image optimiser can stall concurrent first image requests\n // (vercel/next.js#98168), and nothing here needs the images. The expectations below wait for the tag.\n await page.goto(path, { waitUntil: 'domcontentloaded' });\n const loaders = page.locator('script[src*=\"googletagmanager.com/gtag/js\"]');\n await expect(loaders.first(), `${path}: no Google tag rendered (build the e2e target with a test NEXT_PUBLIC_GA_MEASUREMENT_ID)`)\n .toBeAttached({ timeout: 15_000 });\n expect(await loaders.count(), `${path}: renders exactly one Google tag loader`).toBe(1);\n await expect.poll(() => commands.filter((command) => command[0] === 'js').length, `${path}: gtag('js') runs once`).toBe(1);\n const destinations = commands.filter((command) => command[0] === 'config').map((command) => String(command[1]));\n expect(new Set(destinations).size, `${path}: a destination is configured twice`).toBe(destinations.length);\n }\n });\n\n test('sends one phone_link_click per tap', async ({ page }) => {\n await page.goto(paths[0]!, { waitUntil: 'domcontentloaded' });\n const link = options.phoneLink ? page.locator(options.phoneLink).first() : page.locator('a[href^=\"tel:\"]:visible').first();\n await expect(link, 'the page renders a telephone link').toBeVisible();\n await expect.poll(() => commands.some((command) => command[0] === 'js')).toBe(true);\n // Keep the page in place; the shared listener still sees the tap.\n await page.evaluate(() => window.addEventListener('click', (event) => {\n if ((event.target as Element | null)?.closest?.('a[href^=\"tel:\"]')) event.preventDefault();\n }, true));\n await link.click();\n await expect.poll(() => analyticsEvents(commands, 'phone_link_click').length).toBe(1);\n await page.waitForTimeout(PHONE_DEDUPE_WINDOW_MS + 300);\n expect(analyticsEvents(commands, 'phone_link_click'), 'one tap sends one event').toHaveLength(1);\n await link.click();\n await expect.poll(() => analyticsEvents(commands, 'phone_link_click').length).toBe(2);\n for (const event of analyticsEvents(commands, 'phone_link_click')) {\n expect(event.params.placement, 'phone_link_click carries its placement').toBeTruthy();\n }\n expect(analyticsEventProblems(commands, { additionalEvents })).toEqual([]);\n });\n\n if (options.contact) {\n const journey = options.contact;\n test('sends one contact_form only after an accepted enquiry, without lead details', async ({ page }) => {\n let attempts = 0;\n await page.route(contactEndpointMatcher(journey.endpoint), async (route) => {\n attempts += 1;\n await route.fulfill(attempts === 1\n ? { status: 503, json: RETRYABLE_CONTACT_FAILURE }\n : { status: 200, json: ACCEPTED_CONTACT_RESULT });\n });\n await fillBrandlabContactForm(page, journey);\n const submit = page.locator('form').filter({ has: page.locator(`[name=\"${Object.keys(journey.fields)[0]}\"]`) })\n .first().getByRole('button', { name: journey.submit, exact: typeof journey.submit === 'string' });\n await submit.click();\n await expect.poll(() => attempts).toBe(1);\n await page.waitForTimeout(500);\n expect(analyticsEvents(commands, 'contact_form'), 'a failed delivery is not a conversion').toHaveLength(0);\n const retry = journey.retry ?? journey.submit;\n await page.locator('form').filter({ has: page.locator(`[name=\"${Object.keys(journey.fields)[0]}\"]`) })\n .first().getByRole('button', { name: retry, exact: typeof retry === 'string' }).click();\n await expect(page).toHaveURL(successPattern(journey.successPath));\n await expect.poll(() => analyticsEvents(commands, 'contact_form').length).toBe(1);\n await page.waitForTimeout(500);\n expect(analyticsEvents(commands, 'contact_form')).toHaveLength(1);\n expect(analyticsEventProblems(commands, { enteredValues: Object.values(journey.fields), additionalEvents })).toEqual([]);\n });\n }\n\n test('sends no retired or web_vital events', async ({ page }) => {\n for (const path of paths) {\n const tagRuns = commands.filter((command) => command[0] === 'js').length;\n await page.goto(path, { waitUntil: 'domcontentloaded' });\n // The page's instrumentation has run once its Google tag has: any reporter would be listening by then.\n await expect.poll(() => commands.filter((command) => command[0] === 'js').length, `${path}: the Google tag runs`).toBeGreaterThan(tagRuns);\n await page.mouse.wheel(0, 1_200);\n await page.waitForTimeout(300);\n // Web-vitals reporters flush when the page is hidden.\n await page.evaluate(() => {\n Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });\n document.dispatchEvent(new Event('visibilitychange'));\n window.dispatchEvent(new Event('pagehide'));\n });\n await page.waitForTimeout(300);\n }\n expect(analyticsEventProblems(commands, { additionalEvents })).toEqual([]);\n });\n });\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"contact-e2e.d.ts","sourceRoot":"","sources":["../src/contact-e2e.ts"],"names":[],"mappings":"AAiBA,OAAO,EAA8B,KAAK,IAAI,EAAE,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAYvF,MAAM,WAAW,2BAA2B;IAC1C,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACzC,4CAA4C;IAC5C,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,0BAA2B,SAAQ,sBAAsB;IACxE,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,mEAAmE;IACnE,MAAM,CAAC,EAAE,OAAO,GAAG,2BAA2B,CAAC;CAChD;AAID,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,0BAA0B,QA4G7E;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,iBAcxF;AAED,wBAAgB,cAAc,CAAC,WAAW,GAAE,MAAM,GAAG,MAAqB,GAAG,MAAM,CAElF;AAED,yFAAyF;AACzF,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,iBASlG"}
1
+ {"version":3,"file":"contact-e2e.d.ts","sourceRoot":"","sources":["../src/contact-e2e.ts"],"names":[],"mappings":"AAiBA,OAAO,EAA8B,KAAK,IAAI,EAAE,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAYvF,MAAM,WAAW,2BAA2B;IAC1C,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACzC,4CAA4C;IAC5C,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,0BAA2B,SAAQ,sBAAsB;IACxE,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,mEAAmE;IACnE,MAAM,CAAC,EAAE,OAAO,GAAG,2BAA2B,CAAC;CAChD;AAID,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,0BAA0B,QA4G7E;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,iBAgBxF;AAED,wBAAgB,cAAc,CAAC,WAAW,GAAE,MAAM,GAAG,MAAqB,GAAG,MAAM,CAElF;AAED,yFAAyF;AACzF,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,iBASlG"}
@@ -132,8 +132,10 @@ export function defineBrandlabContactTests(options) {
132
132
  */
133
133
  export async function fillBrandlabContactForm(page, journey) {
134
134
  await isolateFromNetwork(page);
135
+ // DOMContentLoaded, not load (vercel/next.js#98168: next start's image optimiser can stall concurrent first
136
+ // image requests); the enabled submit below is the hydration signal the journey needs.
135
137
  if (!new URL(page.url(), 'http://brandlab.invalid').pathname.endsWith(journey.path))
136
- await page.goto(journey.path);
138
+ await page.goto(journey.path, { waitUntil: 'domcontentloaded' });
137
139
  // The shared form keeps submit disabled until React has hydrated its handlers.
138
140
  await expect(submitButton(page, journey)).toBeEnabled({ timeout: 30_000 });
139
141
  const form = contactForm(page, journey);
@@ -1 +1 @@
1
- {"version":3,"file":"contact-e2e.js","sourceRoot":"","sources":["../src/contact-e2e.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared contact-form journeys for a Brandlab production build. The contact\n * endpoint is mocked; every other request leaves the browser only as a GET or\n * HEAD to the build under test, and anything else is aborted (fail closed), so\n * no enquiry or email is ever sent, even if the form posts somewhere unexpected.\n *\n * // tests/e2e/contact.spec.ts\n * import { defineBrandlabContactTests } from '@brandlab-digital/conformance/contact-e2e'\n *\n * defineBrandlabContactTests({\n * path: '/contact',\n * fields: { name: 'Test Visitor', email: 'visitor@example.com', phone: '0400 000 000', message: 'Blocked kitchen drain.' },\n * submit: 'Send enquiry',\n * photos: true,\n * })\n */\nimport { readFileSync } from 'node:fs';\nimport { expect, test, type Locator, type Page, type Request } from '@playwright/test';\nimport {\n ACCEPTED_CONTACT_RESULT,\n RETRYABLE_CONTACT_FAILURE,\n blockedSubmissions,\n contactEndpointMatcher,\n createTestPhoto,\n isolateFromNetwork,\n readContactSubmission,\n type ContactSubmission,\n} from './e2e-support.js';\n\nexport interface BrandlabContactPhotoOptions {\n /** A real image to prepare; a small generated PNG is used when omitted. */\n file?: string;\n /** Accessible name of each prepared photo's remove button. */\n removeButton?: RegExp;\n /** Message shown when a photo cannot be prepared. */\n invalidMessage?: RegExp;\n}\n\nexport interface BrandlabContactJourney {\n /** The page that renders the form, for example '/contact'. */\n path: string;\n /** Values to enter, keyed by the contract field key (the control's name). */\n fields: Readonly<Record<string, string>>;\n /** Accessible name of the submit button. */\n submit: string | RegExp;\n /** Where an accepted enquiry lands; defaults to /thank-you. */\n successPath?: string | RegExp;\n /** Selector of the form; defaults to the form containing the first field. */\n form?: string;\n /**\n * Pathname of the mocked contact endpoint; defaults to the shared /api/contact.\n * Matched by pathname, so a trailing slash or query string is included.\n */\n endpoint?: string;\n}\n\nexport interface BrandlabContactTestOptions extends BrandlabContactJourney {\n /** Accessible name of the retry control after a retryable failure; defaults to submit. */\n retry?: string | RegExp;\n /** Enable the photo journeys when the form accepts attachments. */\n photos?: boolean | BrandlabContactPhotoOptions;\n}\n\nconst INVALID_HEIC = { name: 'invalid.heic', mimeType: 'image/heic', buffer: Buffer.from('invalid HEIC test bytes') };\n\nexport function defineBrandlabContactTests(options: BrandlabContactTestOptions) {\n const matchesEndpoint = contactEndpointMatcher(options.endpoint);\n const photos = options.photos ? (options.photos === true ? {} : options.photos) : null;\n const removeButton = photos?.removeButton ?? /^Remove /;\n const photo = photos?.file\n ? { name: photos.file.split(/[\\\\/]/).at(-1)!, mimeType: mimeTypeOf(photos.file), buffer: readFileSync(photos.file) }\n : { name: 'brandlab-contact-photo.png', mimeType: 'image/png', buffer: createTestPhoto() };\n\n async function mockEndpoint(page: Page, responses: readonly ('failure' | 'success')[]) {\n const requests: Request[] = [];\n await page.route(matchesEndpoint, async (route) => {\n requests.push(route.request());\n const outcome = responses[Math.min(requests.length, responses.length) - 1];\n await route.fulfill(outcome === 'success'\n ? { status: 200, json: ACCEPTED_CONTACT_RESULT }\n : { status: 503, json: RETRYABLE_CONTACT_FAILURE });\n });\n return requests;\n }\n\n async function addPhoto(page: Page) {\n const form = contactForm(page, options);\n await form.locator('input[type=\"file\"]').setInputFiles(photo);\n await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1, { timeout: 30_000 });\n }\n\n test.describe('Brandlab contact form', () => {\n // Service workers could answer requests outside page and context routing.\n test.use({ serviceWorkers: 'block' });\n test.beforeEach(async ({ page, baseURL }) => { await isolateFromNetwork(page, { baseURL }); });\n\n test('cannot be submitted natively before hydration', async ({ page }) => {\n const requests = await mockEndpoint(page, ['success']);\n let releaseScripts!: () => void;\n const scriptsReleased = new Promise<void>((resolve) => { releaseScripts = resolve; });\n await page.route('**/_next/static/**/*.js', async (route) => { await scriptsReleased; await route.fallback(); });\n await page.goto(options.path, { waitUntil: 'domcontentloaded' });\n const nativeSubmissions: string[] = [];\n page.on('request', (request) => {\n if (request.isNavigationRequest() && request.method() !== 'GET') nativeSubmissions.push(request.url());\n });\n const form = contactForm(page, options);\n await expect(form).toHaveAttribute('method', /^post$/i);\n expect(await form.getAttribute('action'), 'the form must not post natively to another URL').toBeNull();\n const submit = form.getByRole('button', { name: options.submit, exact: typeof options.submit === 'string' });\n await expect(submit).toBeDisabled();\n const [firstField] = Object.keys(options.fields);\n if (firstField) {\n await form.locator(`[name=\"${firstField}\"]`).first().press('Enter');\n await page.waitForTimeout(300);\n }\n expect(new URL(page.url()).search, 'a native submission added the enquiry to the URL').toBe('');\n expect(nativeSubmissions, 'the form submitted natively before hydration').toEqual([]);\n expect(requests).toHaveLength(0);\n releaseScripts();\n await expect(submit).toBeEnabled({ timeout: 15_000 });\n });\n\n test('a delivery failure keeps every field and prepared photo', async ({ page }) => {\n const requests = await mockEndpoint(page, ['failure']);\n await fillBrandlabContactForm(page, options);\n if (photos) await addPhoto(page);\n await submitButton(page, options).click();\n await expectContactRequests(page, requests, 1);\n const form = contactForm(page, options);\n await expect(form.getByRole('button', { name: options.retry ?? options.submit, exact: typeof (options.retry ?? options.submit) === 'string' })).toBeVisible();\n await expectFieldValues(form, options.fields);\n if (photos) await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1);\n });\n\n if (photos) {\n test('an unreadable HEIC photo leaves the enquiry and prepared photos intact', async ({ page }) => {\n await mockEndpoint(page, ['failure']);\n await fillBrandlabContactForm(page, options);\n await addPhoto(page);\n const form = contactForm(page, options);\n await form.locator('input[type=\"file\"]').setInputFiles(INVALID_HEIC);\n await expect(page.getByText(photos.invalidMessage ?? /could not prepare this photo/i).first()).toBeVisible({ timeout: 30_000 });\n await expectFieldValues(form, options.fields);\n await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1);\n });\n }\n\n test('retrying after a failure resends the same submission', async ({ page }) => {\n const requests = await mockEndpoint(page, ['failure', 'success']);\n await fillBrandlabContactForm(page, options);\n if (photos) await addPhoto(page);\n await submitButton(page, options).click();\n await expectContactRequests(page, requests, 1);\n const retry = contactForm(page, options).getByRole('button', { name: options.retry ?? options.submit, exact: typeof (options.retry ?? options.submit) === 'string' });\n await retry.click();\n await expectContactRequests(page, requests, 2);\n const [first, second] = await Promise.all(requests.map(readContactSubmission)) as [ContactSubmission, ContactSubmission];\n expect(first.fields.clientSubmissionId, 'the submission carries a client submission ID').toBeTruthy();\n expect(second.fields.clientSubmissionId).toBe(first.fields.clientSubmissionId);\n for (const [name, value] of Object.entries(options.fields)) expect(second.fields[name], name).toBe(value);\n expect(second.attachments).toEqual(first.attachments);\n if (photos) expect(first.attachments).toHaveLength(1);\n });\n\n test('an accepted enquiry is sent once and moves to the thank-you page', async ({ page }) => {\n const requests = await mockEndpoint(page, ['success']);\n await fillBrandlabContactForm(page, options);\n await submitButton(page, options).click();\n await expect(page).toHaveURL(successPattern(options.successPath));\n expect(requests).toHaveLength(1);\n });\n });\n}\n\n/**\n * Open the contact page, wait for hydration and enter every field value. The page\n * is isolated first, so submitting without a mock aborts the request rather than\n * delivering an enquiry.\n */\nexport async function fillBrandlabContactForm(page: Page, journey: BrandlabContactJourney) {\n await isolateFromNetwork(page);\n if (!new URL(page.url(), 'http://brandlab.invalid').pathname.endsWith(journey.path)) await page.goto(journey.path);\n // The shared form keeps submit disabled until React has hydrated its handlers.\n await expect(submitButton(page, journey)).toBeEnabled({ timeout: 30_000 });\n const form = contactForm(page, journey);\n for (const [name, value] of Object.entries(journey.fields)) {\n const control = form.locator(`[name=\"${name}\"]`).first();\n const tag = await control.evaluate((element) => element.tagName);\n const type = await control.getAttribute('type');\n if (tag === 'SELECT') await control.selectOption(value);\n else if (type === 'checkbox' || type === 'radio') await control.check();\n else await control.fill(value);\n }\n}\n\nexport function successPattern(successPath: string | RegExp = '/thank-you'): RegExp {\n return typeof successPath === 'string' ? new RegExp(`${successPath.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(?:[?#].*)?$`) : successPath;\n}\n\n/** Wait for mocked contact requests, naming any submission the guard blocked instead. */\nexport async function expectContactRequests(page: Page, requests: readonly Request[], count: number) {\n try {\n await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(count);\n } catch (error) {\n const blocked = blockedSubmissions(page);\n throw new Error(blocked.length > 0\n ? `Expected ${count} request(s) to the mocked contact endpoint; the form sent ${blocked.join(', ')}, which was aborted. Set the endpoint option to the form's pathname.`\n : `Expected ${count} request(s) to the mocked contact endpoint; ${requests.length} arrived.`, { cause: error });\n }\n}\n\nfunction contactForm(page: Page, journey: BrandlabContactJourney): Locator {\n if (journey.form) return page.locator(journey.form).first();\n const [firstField] = Object.keys(journey.fields);\n return page.locator('form').filter({ has: page.locator(`[name=\"${firstField}\"]`) }).first();\n}\n\nfunction submitButton(page: Page, journey: BrandlabContactJourney): Locator {\n return contactForm(page, journey).getByRole('button', { name: journey.submit, exact: typeof journey.submit === 'string' });\n}\n\nasync function expectFieldValues(form: Locator, fields: Readonly<Record<string, string>>) {\n for (const [name, value] of Object.entries(fields)) {\n const control = form.locator(`[name=\"${name}\"]`).first();\n const type = await control.getAttribute('type');\n if (type === 'checkbox' || type === 'radio') await expect(control, name).toBeChecked();\n else await expect(control, name).toHaveValue(value);\n }\n}\n\nfunction mimeTypeOf(file: string): string {\n const extension = file.split('.').at(-1)?.toLowerCase();\n return extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'\n : extension === 'webp' ? 'image/webp'\n : extension === 'heic' ? 'image/heic'\n : 'image/png';\n}\n"]}
1
+ {"version":3,"file":"contact-e2e.js","sourceRoot":"","sources":["../src/contact-e2e.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The shared contact-form journeys for a Brandlab production build. The contact\n * endpoint is mocked; every other request leaves the browser only as a GET or\n * HEAD to the build under test, and anything else is aborted (fail closed), so\n * no enquiry or email is ever sent, even if the form posts somewhere unexpected.\n *\n * // tests/e2e/contact.spec.ts\n * import { defineBrandlabContactTests } from '@brandlab-digital/conformance/contact-e2e'\n *\n * defineBrandlabContactTests({\n * path: '/contact',\n * fields: { name: 'Test Visitor', email: 'visitor@example.com', phone: '0400 000 000', message: 'Blocked kitchen drain.' },\n * submit: 'Send enquiry',\n * photos: true,\n * })\n */\nimport { readFileSync } from 'node:fs';\nimport { expect, test, type Locator, type Page, type Request } from '@playwright/test';\nimport {\n ACCEPTED_CONTACT_RESULT,\n RETRYABLE_CONTACT_FAILURE,\n blockedSubmissions,\n contactEndpointMatcher,\n createTestPhoto,\n isolateFromNetwork,\n readContactSubmission,\n type ContactSubmission,\n} from './e2e-support.js';\n\nexport interface BrandlabContactPhotoOptions {\n /** A real image to prepare; a small generated PNG is used when omitted. */\n file?: string;\n /** Accessible name of each prepared photo's remove button. */\n removeButton?: RegExp;\n /** Message shown when a photo cannot be prepared. */\n invalidMessage?: RegExp;\n}\n\nexport interface BrandlabContactJourney {\n /** The page that renders the form, for example '/contact'. */\n path: string;\n /** Values to enter, keyed by the contract field key (the control's name). */\n fields: Readonly<Record<string, string>>;\n /** Accessible name of the submit button. */\n submit: string | RegExp;\n /** Where an accepted enquiry lands; defaults to /thank-you. */\n successPath?: string | RegExp;\n /** Selector of the form; defaults to the form containing the first field. */\n form?: string;\n /**\n * Pathname of the mocked contact endpoint; defaults to the shared /api/contact.\n * Matched by pathname, so a trailing slash or query string is included.\n */\n endpoint?: string;\n}\n\nexport interface BrandlabContactTestOptions extends BrandlabContactJourney {\n /** Accessible name of the retry control after a retryable failure; defaults to submit. */\n retry?: string | RegExp;\n /** Enable the photo journeys when the form accepts attachments. */\n photos?: boolean | BrandlabContactPhotoOptions;\n}\n\nconst INVALID_HEIC = { name: 'invalid.heic', mimeType: 'image/heic', buffer: Buffer.from('invalid HEIC test bytes') };\n\nexport function defineBrandlabContactTests(options: BrandlabContactTestOptions) {\n const matchesEndpoint = contactEndpointMatcher(options.endpoint);\n const photos = options.photos ? (options.photos === true ? {} : options.photos) : null;\n const removeButton = photos?.removeButton ?? /^Remove /;\n const photo = photos?.file\n ? { name: photos.file.split(/[\\\\/]/).at(-1)!, mimeType: mimeTypeOf(photos.file), buffer: readFileSync(photos.file) }\n : { name: 'brandlab-contact-photo.png', mimeType: 'image/png', buffer: createTestPhoto() };\n\n async function mockEndpoint(page: Page, responses: readonly ('failure' | 'success')[]) {\n const requests: Request[] = [];\n await page.route(matchesEndpoint, async (route) => {\n requests.push(route.request());\n const outcome = responses[Math.min(requests.length, responses.length) - 1];\n await route.fulfill(outcome === 'success'\n ? { status: 200, json: ACCEPTED_CONTACT_RESULT }\n : { status: 503, json: RETRYABLE_CONTACT_FAILURE });\n });\n return requests;\n }\n\n async function addPhoto(page: Page) {\n const form = contactForm(page, options);\n await form.locator('input[type=\"file\"]').setInputFiles(photo);\n await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1, { timeout: 30_000 });\n }\n\n test.describe('Brandlab contact form', () => {\n // Service workers could answer requests outside page and context routing.\n test.use({ serviceWorkers: 'block' });\n test.beforeEach(async ({ page, baseURL }) => { await isolateFromNetwork(page, { baseURL }); });\n\n test('cannot be submitted natively before hydration', async ({ page }) => {\n const requests = await mockEndpoint(page, ['success']);\n let releaseScripts!: () => void;\n const scriptsReleased = new Promise<void>((resolve) => { releaseScripts = resolve; });\n await page.route('**/_next/static/**/*.js', async (route) => { await scriptsReleased; await route.fallback(); });\n await page.goto(options.path, { waitUntil: 'domcontentloaded' });\n const nativeSubmissions: string[] = [];\n page.on('request', (request) => {\n if (request.isNavigationRequest() && request.method() !== 'GET') nativeSubmissions.push(request.url());\n });\n const form = contactForm(page, options);\n await expect(form).toHaveAttribute('method', /^post$/i);\n expect(await form.getAttribute('action'), 'the form must not post natively to another URL').toBeNull();\n const submit = form.getByRole('button', { name: options.submit, exact: typeof options.submit === 'string' });\n await expect(submit).toBeDisabled();\n const [firstField] = Object.keys(options.fields);\n if (firstField) {\n await form.locator(`[name=\"${firstField}\"]`).first().press('Enter');\n await page.waitForTimeout(300);\n }\n expect(new URL(page.url()).search, 'a native submission added the enquiry to the URL').toBe('');\n expect(nativeSubmissions, 'the form submitted natively before hydration').toEqual([]);\n expect(requests).toHaveLength(0);\n releaseScripts();\n await expect(submit).toBeEnabled({ timeout: 15_000 });\n });\n\n test('a delivery failure keeps every field and prepared photo', async ({ page }) => {\n const requests = await mockEndpoint(page, ['failure']);\n await fillBrandlabContactForm(page, options);\n if (photos) await addPhoto(page);\n await submitButton(page, options).click();\n await expectContactRequests(page, requests, 1);\n const form = contactForm(page, options);\n await expect(form.getByRole('button', { name: options.retry ?? options.submit, exact: typeof (options.retry ?? options.submit) === 'string' })).toBeVisible();\n await expectFieldValues(form, options.fields);\n if (photos) await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1);\n });\n\n if (photos) {\n test('an unreadable HEIC photo leaves the enquiry and prepared photos intact', async ({ page }) => {\n await mockEndpoint(page, ['failure']);\n await fillBrandlabContactForm(page, options);\n await addPhoto(page);\n const form = contactForm(page, options);\n await form.locator('input[type=\"file\"]').setInputFiles(INVALID_HEIC);\n await expect(page.getByText(photos.invalidMessage ?? /could not prepare this photo/i).first()).toBeVisible({ timeout: 30_000 });\n await expectFieldValues(form, options.fields);\n await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1);\n });\n }\n\n test('retrying after a failure resends the same submission', async ({ page }) => {\n const requests = await mockEndpoint(page, ['failure', 'success']);\n await fillBrandlabContactForm(page, options);\n if (photos) await addPhoto(page);\n await submitButton(page, options).click();\n await expectContactRequests(page, requests, 1);\n const retry = contactForm(page, options).getByRole('button', { name: options.retry ?? options.submit, exact: typeof (options.retry ?? options.submit) === 'string' });\n await retry.click();\n await expectContactRequests(page, requests, 2);\n const [first, second] = await Promise.all(requests.map(readContactSubmission)) as [ContactSubmission, ContactSubmission];\n expect(first.fields.clientSubmissionId, 'the submission carries a client submission ID').toBeTruthy();\n expect(second.fields.clientSubmissionId).toBe(first.fields.clientSubmissionId);\n for (const [name, value] of Object.entries(options.fields)) expect(second.fields[name], name).toBe(value);\n expect(second.attachments).toEqual(first.attachments);\n if (photos) expect(first.attachments).toHaveLength(1);\n });\n\n test('an accepted enquiry is sent once and moves to the thank-you page', async ({ page }) => {\n const requests = await mockEndpoint(page, ['success']);\n await fillBrandlabContactForm(page, options);\n await submitButton(page, options).click();\n await expect(page).toHaveURL(successPattern(options.successPath));\n expect(requests).toHaveLength(1);\n });\n });\n}\n\n/**\n * Open the contact page, wait for hydration and enter every field value. The page\n * is isolated first, so submitting without a mock aborts the request rather than\n * delivering an enquiry.\n */\nexport async function fillBrandlabContactForm(page: Page, journey: BrandlabContactJourney) {\n await isolateFromNetwork(page);\n // DOMContentLoaded, not load (vercel/next.js#98168: next start's image optimiser can stall concurrent first\n // image requests); the enabled submit below is the hydration signal the journey needs.\n if (!new URL(page.url(), 'http://brandlab.invalid').pathname.endsWith(journey.path)) await page.goto(journey.path, { waitUntil: 'domcontentloaded' });\n // The shared form keeps submit disabled until React has hydrated its handlers.\n await expect(submitButton(page, journey)).toBeEnabled({ timeout: 30_000 });\n const form = contactForm(page, journey);\n for (const [name, value] of Object.entries(journey.fields)) {\n const control = form.locator(`[name=\"${name}\"]`).first();\n const tag = await control.evaluate((element) => element.tagName);\n const type = await control.getAttribute('type');\n if (tag === 'SELECT') await control.selectOption(value);\n else if (type === 'checkbox' || type === 'radio') await control.check();\n else await control.fill(value);\n }\n}\n\nexport function successPattern(successPath: string | RegExp = '/thank-you'): RegExp {\n return typeof successPath === 'string' ? new RegExp(`${successPath.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(?:[?#].*)?$`) : successPath;\n}\n\n/** Wait for mocked contact requests, naming any submission the guard blocked instead. */\nexport async function expectContactRequests(page: Page, requests: readonly Request[], count: number) {\n try {\n await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(count);\n } catch (error) {\n const blocked = blockedSubmissions(page);\n throw new Error(blocked.length > 0\n ? `Expected ${count} request(s) to the mocked contact endpoint; the form sent ${blocked.join(', ')}, which was aborted. Set the endpoint option to the form's pathname.`\n : `Expected ${count} request(s) to the mocked contact endpoint; ${requests.length} arrived.`, { cause: error });\n }\n}\n\nfunction contactForm(page: Page, journey: BrandlabContactJourney): Locator {\n if (journey.form) return page.locator(journey.form).first();\n const [firstField] = Object.keys(journey.fields);\n return page.locator('form').filter({ has: page.locator(`[name=\"${firstField}\"]`) }).first();\n}\n\nfunction submitButton(page: Page, journey: BrandlabContactJourney): Locator {\n return contactForm(page, journey).getByRole('button', { name: journey.submit, exact: typeof journey.submit === 'string' });\n}\n\nasync function expectFieldValues(form: Locator, fields: Readonly<Record<string, string>>) {\n for (const [name, value] of Object.entries(fields)) {\n const control = form.locator(`[name=\"${name}\"]`).first();\n const type = await control.getAttribute('type');\n if (type === 'checkbox' || type === 'radio') await expect(control, name).toBeChecked();\n else await expect(control, name).toHaveValue(value);\n }\n}\n\nfunction mimeTypeOf(file: string): string {\n const extension = file.split('.').at(-1)?.toLowerCase();\n return extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'\n : extension === 'webp' ? 'image/webp'\n : extension === 'heic' ? 'image/heic'\n : 'image/png';\n}\n"]}
@@ -23,19 +23,19 @@ export const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze({
23
23
  "kind": "brandlab-runtime-release-contract",
24
24
  "packages": {
25
25
  "@brandlab-digital/conformance": {
26
- "version": "3.2.0"
26
+ "version": "3.3.0"
27
27
  },
28
28
  "@brandlab-digital/eslint-plugin": {
29
- "version": "3.2.0"
29
+ "version": "3.3.0"
30
30
  },
31
31
  "@brandlab-digital/site-core": {
32
- "version": "3.2.0"
32
+ "version": "3.3.0"
33
33
  },
34
34
  "@brandlab-digital/site-next": {
35
- "version": "3.2.0"
35
+ "version": "3.3.0"
36
36
  }
37
37
  },
38
- "releaseVersion": "3.2.0",
38
+ "releaseVersion": "3.3.0",
39
39
  "runtime": {
40
40
  "bundler": "turbopack",
41
41
  "cacheComponents": false,
@@ -1 +1 @@
1
- {"version":3,"file":"current-release.js","sourceRoot":"","sources":["../src/current-release.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,wEAAwE;AACxE,OAAO,EAEL,sCAAsC,GACvC,MAAM,uBAAuB,CAAC;AAE/B,MAAM,CAAC,MAAM,gCAAgC,GAAG,MAAM,CAAC,MAAM,CAC7D;IACE,cAAc,EAAE;QACd,uCAAuC;QACvC,8BAA8B;QAC9B,yBAAyB;QACzB,6BAA6B;QAC7B,0BAA0B;QAC1B,4BAA4B;QAC5B,kCAAkC;QAClC,wCAAwC;QACxC,yBAAyB;KAC1B;IACD,WAAW,EAAE;QACX,WAAW,EAAE,cAAc;QAC3B,cAAc,EAAE,iBAAiB;QACjC,eAAe,EAAE,mBAAmB;QACpC,SAAS,EAAE,YAAY;QACvB,KAAK,EAAE,QAAQ;KAChB;IACD,MAAM,EAAE,mCAAmC;IAC3C,UAAU,EAAE;QACV,+BAA+B,EAAE;YAC/B,SAAS,EAAE,OAAO;SACnB;QACD,iCAAiC,EAAE;YACjC,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;KACF;IACD,gBAAgB,EAAE,OAAO;IACzB,SAAS,EAAE;QACT,SAAS,EAAE,WAAW;QACtB,iBAAiB,EAAE,KAAK;QACxB,MAAM,EAAE,QAAQ;QAChB,mBAAmB,EAAE,OAAO;QAC5B,MAAM,EAAE,MAAM;QACd,gBAAgB,EAAE,aAAa;QAC/B,OAAO,EAAE,QAAQ;QACjB,UAAU,EAAE,QAAQ;QACpB,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,OAAO;KACtB;IACD,eAAe,EAAE,sCAAsC;CACxD,CAC6C,CAAC;AAE/C,sCAAsC,CAAC,gCAAgC,CAAC,CAAC","sourcesContent":["// Generated by standards/scripts/generate-conformance-current-release.mjs.\n// Each exact package release verifies its own current runtime contract.\nimport {\n type BrandlabRuntimeReleaseContract,\n validateBrandlabRuntimeReleaseContract,\n} from \"./release-manifest.js\";\n\nexport const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze(\n{\n \"capabilities\": [\n \"typed-project-business-and-route-data\",\n \"essential-search-foundations\",\n \"shared-contact-delivery\",\n \"privacy-safe-lead-analytics\",\n \"security-patched-runtime\",\n \"live-google-review-summary\",\n \"high-signal-project-verification\",\n \"packed-positive-and-negative-consumers\",\n \"changesets-oidc-release\"\n ],\n \"contracts\": {\n \"analytics\": \"analytics-v4\",\n \"architecture\": \"architecture-v7\",\n \"configuration\": \"project-config-v7\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"3.2.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"3.2.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"3.2.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"3.2.0\"\n }\n },\n \"releaseVersion\": \"3.2.0\",\n \"runtime\": {\n \"bundler\": \"turbopack\",\n \"cacheComponents\": false,\n \"next\": \"16.3.6\",\n \"nextVersionPolicy\": \"exact\",\n \"node\": \"24.x\",\n \"packageManager\": \"npm@11.14.1\",\n \"react\": \"19.2.8\",\n \"reactDom\": \"19.2.8\",\n \"router\": \"app\",\n \"sharp\": \"0.35.4\",\n \"typescript\": \"6.0.3\"\n },\n \"schemaVersion\": \"brandlab.runtime-release-contract.v1\"\n},\n) as unknown as BrandlabRuntimeReleaseContract;\n\nvalidateBrandlabRuntimeReleaseContract(BRANDLAB_CURRENT_RUNTIME_RELEASE);\n"]}
1
+ {"version":3,"file":"current-release.js","sourceRoot":"","sources":["../src/current-release.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,wEAAwE;AACxE,OAAO,EAEL,sCAAsC,GACvC,MAAM,uBAAuB,CAAC;AAE/B,MAAM,CAAC,MAAM,gCAAgC,GAAG,MAAM,CAAC,MAAM,CAC7D;IACE,cAAc,EAAE;QACd,uCAAuC;QACvC,8BAA8B;QAC9B,yBAAyB;QACzB,6BAA6B;QAC7B,0BAA0B;QAC1B,4BAA4B;QAC5B,kCAAkC;QAClC,wCAAwC;QACxC,yBAAyB;KAC1B;IACD,WAAW,EAAE;QACX,WAAW,EAAE,cAAc;QAC3B,cAAc,EAAE,iBAAiB;QACjC,eAAe,EAAE,mBAAmB;QACpC,SAAS,EAAE,YAAY;QACvB,KAAK,EAAE,QAAQ;KAChB;IACD,MAAM,EAAE,mCAAmC;IAC3C,UAAU,EAAE;QACV,+BAA+B,EAAE;YAC/B,SAAS,EAAE,OAAO;SACnB;QACD,iCAAiC,EAAE;YACjC,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;KACF;IACD,gBAAgB,EAAE,OAAO;IACzB,SAAS,EAAE;QACT,SAAS,EAAE,WAAW;QACtB,iBAAiB,EAAE,KAAK;QACxB,MAAM,EAAE,QAAQ;QAChB,mBAAmB,EAAE,OAAO;QAC5B,MAAM,EAAE,MAAM;QACd,gBAAgB,EAAE,aAAa;QAC/B,OAAO,EAAE,QAAQ;QACjB,UAAU,EAAE,QAAQ;QACpB,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,OAAO;KACtB;IACD,eAAe,EAAE,sCAAsC;CACxD,CAC6C,CAAC;AAE/C,sCAAsC,CAAC,gCAAgC,CAAC,CAAC","sourcesContent":["// Generated by standards/scripts/generate-conformance-current-release.mjs.\n// Each exact package release verifies its own current runtime contract.\nimport {\n type BrandlabRuntimeReleaseContract,\n validateBrandlabRuntimeReleaseContract,\n} from \"./release-manifest.js\";\n\nexport const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze(\n{\n \"capabilities\": [\n \"typed-project-business-and-route-data\",\n \"essential-search-foundations\",\n \"shared-contact-delivery\",\n \"privacy-safe-lead-analytics\",\n \"security-patched-runtime\",\n \"live-google-review-summary\",\n \"high-signal-project-verification\",\n \"packed-positive-and-negative-consumers\",\n \"changesets-oidc-release\"\n ],\n \"contracts\": {\n \"analytics\": \"analytics-v4\",\n \"architecture\": \"architecture-v7\",\n \"configuration\": \"project-config-v7\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"3.3.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"3.3.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"3.3.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"3.3.0\"\n }\n },\n \"releaseVersion\": \"3.3.0\",\n \"runtime\": {\n \"bundler\": \"turbopack\",\n \"cacheComponents\": false,\n \"next\": \"16.3.6\",\n \"nextVersionPolicy\": \"exact\",\n \"node\": \"24.x\",\n \"packageManager\": \"npm@11.14.1\",\n \"react\": \"19.2.8\",\n \"reactDom\": \"19.2.8\",\n \"router\": \"app\",\n \"sharp\": \"0.35.4\",\n \"typescript\": \"6.0.3\"\n },\n \"schemaVersion\": \"brandlab.runtime-release-contract.v1\"\n},\n) as unknown as BrandlabRuntimeReleaseContract;\n\nvalidateBrandlabRuntimeReleaseContract(BRANDLAB_CURRENT_RUNTIME_RELEASE);\n"]}
@@ -17,6 +17,8 @@ export interface EngineeringCheck {
17
17
  coverage: string;
18
18
  outstandingEvidence: readonly string[];
19
19
  }
20
+ /** The Vercel function region of every Brandlab site (3.2.1): Sydney. */
21
+ export declare const BRANDLAB_FUNCTION_REGION = "syd1";
20
22
  /** Opt-in structural check. Static success must never masquerade as whole-project acceptance. */
21
23
  export declare function checkEngineeringStructure(root: string): EngineeringCheck;
22
24
  /** Split a shell command on its chaining operators. */
@@ -1 +1 @@
1
- {"version":3,"file":"engineering.d.ts","sourceRoot":"","sources":["../src/engineering.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,yBAAyB,sGAAuG,CAAC;AAE9I,oFAAoF;AACpF,eAAO,MAAM,+BAA+B,4IAC+F,CAAC;AAE5I,iFAAiF;AACjF,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASrE,CAAC;AAEH,MAAM,MAAM,0BAA0B,GAAG,SAAS,GAAG,MAAM,CAAC;AAC5D,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,0BAA0B,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE;AACxI,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAqBD,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAqBxE;AAwCD,uDAAuD;AACvD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAE3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,EAAE,GAAG,IAAI,CA2B5G"}
1
+ {"version":3,"file":"engineering.d.ts","sourceRoot":"","sources":["../src/engineering.ts"],"names":[],"mappings":"AAmBA,eAAO,MAAM,yBAAyB,sGAAuG,CAAC;AAE9I,oFAAoF;AACpF,eAAO,MAAM,+BAA+B,4IAC+F,CAAC;AAE5I,iFAAiF;AACjF,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASrE,CAAC;AAEH,MAAM,MAAM,0BAA0B,GAAG,SAAS,GAAG,MAAM,CAAC;AAC5D,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,0BAA0B,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE;AACxI,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAqBD,yEAAyE;AACzE,eAAO,MAAM,wBAAwB,SAAS,CAAC;AAS/C,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAyBxE;AAwCD,uDAAuD;AACvD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAE3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,EAAE,GAAG,IAAI,CA2B5G"}
@@ -29,8 +29,18 @@ const CODE_OR_STYLE = /\.(?:[cm]?[jt]sx?|mdx|css)$/;
29
29
  const PASCAL_COMPONENT = /^[A-Z][A-Za-z0-9]*\.tsx$/;
30
30
  const CAMEL_MODULE = /^[a-z][A-Za-z0-9]*(?:\.d)?\.ts$/;
31
31
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
32
+ /** The Vercel function region of every Brandlab site (3.2.1): Sydney. */
33
+ export const BRANDLAB_FUNCTION_REGION = 'syd1';
34
+ /** The contact route's delivery options (labels, email style, attachment policy): server-only (3.2.1). */
35
+ const CONTACT_RUNTIME = 'src/config/contactRuntime.ts';
36
+ /** The business facts' evidence (defineBrandlabBusiness): server-only (3.2.1). */
37
+ const BUSINESS_EVIDENCE = 'src/data/businessEvidence.ts';
38
+ const BUSINESS_VALUES = 'src/data/business.ts';
39
+ const SITE_IDENTITY = 'src/config/site.ts';
40
+ const BUSINESS_FACT_CALLS = /\b(?:defineBrandlabBusiness|verifiedFact|unknownFact|notApplicableFact)\s*\(/;
32
41
  /** Opt-in structural check. Static success must never masquerade as whole-project acceptance. */
33
42
  export function checkEngineeringStructure(root) {
43
+ directoryListings.clear();
34
44
  const findings = [];
35
45
  const add = (path, rule, message, extra = {}) => findings.push({ path, rule, message, severity: extra.severity ?? 'blocker', ...(extra.line ? { line: extra.line } : {}) });
36
46
  let pkg = {};
@@ -42,10 +52,12 @@ export function checkEngineeringStructure(root) {
42
52
  }
43
53
  const config = existsSync(join(root, 'brandlab.config.ts')) ? readFileSync(join(root, 'brandlab.config.ts'), 'utf8') : null;
44
54
  const capabilities = describeCapabilities(root, config);
55
+ const source = collectBrandlabSource(root);
45
56
  inspectCommands(pkg, add);
46
57
  inspectDependencies(pkg, add);
47
58
  inspectLayout(root, capabilities, add);
48
- inspectSourceModules(root, add);
59
+ inspectSourceModules(root, source, add);
60
+ inspectServerBoundaries(root, capabilities, source, add);
49
61
  inspectTests(root, capabilities, add);
50
62
  inspectToolPresets(root, add);
51
63
  inspectDelivery(root, add);
@@ -173,7 +185,8 @@ function inspectLayout(root, capabilities, add) {
173
185
  const required = [
174
186
  'src/config/site.ts', 'src/config/analytics.ts', 'src/data/business.ts', 'src/data/routes.ts', 'src/data/redirects.ts',
175
187
  'src/lib/seo/metadata.ts', 'src/instrumentation-client.ts', 'src/app/layout.tsx', 'src/app/robots.ts', 'src/app/sitemap.xml/route.ts',
176
- ...(capabilities.contactEnabled ? ['src/config/contact.ts', 'src/app/api/contact/route.ts'] : []),
188
+ 'src/data/businessEvidence.ts',
189
+ ...(capabilities.contactEnabled ? ['src/config/contact.ts', CONTACT_RUNTIME, 'src/app/api/contact/route.ts'] : []),
177
190
  ...(capabilities.reviewsEnabled ? ['src/config/reviews.ts'] : []),
178
191
  ...(capabilities.locationsDeclared ? ['src/data/locations/estate.ts'] : []),
179
192
  ];
@@ -233,8 +246,11 @@ function inspectFileName(root, path, add) {
233
246
  return;
234
247
  if (name.endsWith('.module.css')) {
235
248
  const component = name.replace(/\.module\.css$/, '');
236
- if (!['tsx', 'ts', 'jsx'].some(extension => existsSync(join(root, directory, `${component}.${extension}`)))) {
237
- add(path, 'file-naming', `Keep ${name} beside the ${component} component it styles.`);
249
+ // Exact names from the directory listing: existsSync is case-insensitive on macOS but not on the Linux CI
250
+ // (H&R's button.module.css beside Button.tsx passed locally and failed in CI).
251
+ const siblings = directoryEntries(join(root, directory));
252
+ if (!['tsx', 'ts', 'jsx'].some(extension => siblings.has(`${component}.${extension}`))) {
253
+ add(path, 'file-naming', `Keep ${name} beside the ${component} component it styles (the file names must match exactly, including case).`);
238
254
  }
239
255
  return;
240
256
  }
@@ -268,8 +284,7 @@ function inspectFileName(root, path, add) {
268
284
  }
269
285
  }
270
286
  // --- module structure: barrels, ui data imports, estate ----------------------
271
- function inspectSourceModules(root, add) {
272
- const source = collectBrandlabSource(root);
287
+ function inspectSourceModules(root, source, add) {
273
288
  const resolver = createBrandlabModuleResolver(root, new Set(source.byPath.keys()));
274
289
  for (const file of source.files.filter(candidate => candidate.path.startsWith('src/'))) {
275
290
  const references = listModuleReferences(file);
@@ -298,6 +313,156 @@ function inspectSourceModules(root, add) {
298
313
  inspectSiteOwnedLocations(estate, add);
299
314
  }
300
315
  }
316
+ // --- server-only boundaries (3.2.1) ------------------------------------------
317
+ /**
318
+ * What a browser bundle must never carry. The contact route's delivery options and
319
+ * the business evidence live in server-only modules, alias hosts in next.config, and
320
+ * no module a client component reaches imports a server-only module or holds an
321
+ * evidence record. (Next's build also refuses a client import of `server-only`; this
322
+ * names the chain before a build is needed.)
323
+ */
324
+ function inspectServerBoundaries(root, capabilities, source, add) {
325
+ const contact = source.byPath.get('src/config/contact.ts');
326
+ const runtime = source.byPath.get(CONTACT_RUNTIME);
327
+ if (capabilities.contactEnabled && runtime) {
328
+ if (!importsServerOnly(runtime)) {
329
+ add(CONTACT_RUNTIME, 'server-only-module', `${CONTACT_RUNTIME} imports 'server-only', so no client component can bundle the delivery options.`);
330
+ }
331
+ if (!declaresName(runtime, 'contactRuntime')) {
332
+ add(CONTACT_RUNTIME, 'contact-runtime', `${CONTACT_RUNTIME} exports contactRuntime (the labels, email style and attachment policy createBrandlabContactRouteHandler takes).`);
333
+ }
334
+ }
335
+ const moved = contact ? declaresName(contact, 'contactRuntime') : undefined;
336
+ if (contact && moved) {
337
+ add('src/config/contact.ts', 'contact-runtime', `contactRuntime belongs in ${CONTACT_RUNTIME}: the contact form imports src/config/contact.ts, so everything contactRuntime reads (the site identity, business data) reaches the browser.`, { line: moved });
338
+ }
339
+ const evidence = source.byPath.get(BUSINESS_EVIDENCE);
340
+ if (evidence) {
341
+ if (!importsServerOnly(evidence)) {
342
+ add(BUSINESS_EVIDENCE, 'server-only-module', `${BUSINESS_EVIDENCE} imports 'server-only', so no client component can bundle the evidence records.`);
343
+ }
344
+ if (findImportedCalls(evidence, /^@brandlab-digital\/site-core\/business$/, 'defineBrandlabBusiness').length === 0) {
345
+ add(BUSINESS_EVIDENCE, 'business-evidence', `${BUSINESS_EVIDENCE} declares the business facts with defineBrandlabBusiness, each with its evidence.`);
346
+ }
347
+ }
348
+ const values = source.byPath.get(BUSINESS_VALUES);
349
+ if (values) {
350
+ const call = BUSINESS_FACT_CALLS.exec(values.executable);
351
+ const record = evidenceRecordLines(values)[0];
352
+ if (call || record) {
353
+ add(BUSINESS_VALUES, 'business-evidence', `${BUSINESS_VALUES} holds the display values only (businessFacts); defineBrandlabBusiness and the evidence belong in ${BUSINESS_EVIDENCE}, which stays on the server.`, { line: record ?? lineAtIndex(values.text, call.index) });
354
+ }
355
+ }
356
+ const site = source.byPath.get(SITE_IDENTITY);
357
+ const aliasHosts = site ? declaredAliasHosts(site) : undefined;
358
+ if (aliasHosts) {
359
+ add(SITE_IDENTITY, 'alias-hosts', 'Declare the alias hosts in next.config.ts with defineBrandlabNextConfig({ aliasHosts }): client components import the site identity, so its alias hosts reach the browser.', { line: aliasHosts });
360
+ }
361
+ const resolver = createBrandlabModuleResolver(root, new Set(source.byPath.keys()));
362
+ const reached = new Map();
363
+ const queue = source.files.filter(file => file.path.startsWith('src/') && (isClientModule(file) || /^src\/instrumentation-client\.[cm]?[jt]sx?$/.test(file.path)));
364
+ for (const file of queue)
365
+ reached.set(file.path, null);
366
+ while (queue.length > 0) {
367
+ const file = queue.shift();
368
+ for (const reference of listModuleReferences(file)) {
369
+ if (reference.typeOnly)
370
+ continue;
371
+ const target = resolver(file.path, reference.specifier);
372
+ if (!target || reached.has(target))
373
+ continue;
374
+ reached.set(target, file.path);
375
+ const next = source.byPath.get(target);
376
+ if (next)
377
+ queue.push(next);
378
+ }
379
+ }
380
+ const chain = (path) => {
381
+ const steps = [];
382
+ for (let at = path; at; at = reached.get(at))
383
+ steps.unshift(at);
384
+ return steps.join(' → ');
385
+ };
386
+ for (const path of [...reached.keys()].sort()) {
387
+ const file = source.byPath.get(path);
388
+ if (!file)
389
+ continue;
390
+ if (importsServerOnly(file)) {
391
+ add(path, 'client-bundle-boundary', `A client component imports this server-only module (${chain(path)}); pass what it needs as props from a server component.`);
392
+ continue;
393
+ }
394
+ const record = evidenceRecordLines(file)[0];
395
+ if (record) {
396
+ add(path, 'client-bundle-boundary', `An evidence record (sourceKind, verifiedAt) reaches the browser (${chain(path)}); keep evidence in ${BUSINESS_EVIDENCE}.`, { line: record });
397
+ }
398
+ }
399
+ }
400
+ /** A module whose directive prologue says 'use client'. */
401
+ function isClientModule(file) {
402
+ for (const statement of parseBrandlabSourceFile(file).statements) {
403
+ if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
404
+ return false;
405
+ if (statement.expression.text === 'use client')
406
+ return true;
407
+ }
408
+ return false;
409
+ }
410
+ /** `import 'server-only'` at the top level. */
411
+ function importsServerOnly(file) {
412
+ return parseBrandlabSourceFile(file).statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause &&
413
+ ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === 'server-only');
414
+ }
415
+ /** The line of a top-level declaration of `name`, if the module declares it. */
416
+ function declaresName(file, name) {
417
+ for (const statement of parseBrandlabSourceFile(file).statements) {
418
+ if (ts.isVariableStatement(statement)) {
419
+ for (const declaration of statement.declarationList.declarations) {
420
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
421
+ return lineOfNode(file, statement);
422
+ }
423
+ }
424
+ else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name) {
425
+ return lineOfNode(file, statement);
426
+ }
427
+ }
428
+ return undefined;
429
+ }
430
+ /** Lines of object literals that are evidence records: they name both sourceKind and verifiedAt. */
431
+ function evidenceRecordLines(file) {
432
+ const lines = [];
433
+ const visit = (node) => {
434
+ if (ts.isObjectLiteralExpression(node)) {
435
+ const names = new Set(node.properties.map(property => property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : ''));
436
+ if (names.has('sourceKind') && names.has('verifiedAt'))
437
+ lines.push(lineOfNode(file, node));
438
+ }
439
+ ts.forEachChild(node, visit);
440
+ };
441
+ visit(parseBrandlabSourceFile(file));
442
+ return lines;
443
+ }
444
+ /** The line of a non-empty `aliasHosts` in the defineBrandlabSite call. */
445
+ function declaredAliasHosts(file) {
446
+ for (const call of findImportedCalls(file, /^@brandlab-digital\/site-core\/site$/, 'defineBrandlabSite')) {
447
+ let definition = call.arguments[0];
448
+ while (definition && (ts.isAsExpression(definition) || ts.isSatisfiesExpression(definition) || ts.isParenthesizedExpression(definition))) {
449
+ definition = definition.expression;
450
+ }
451
+ if (!definition || !ts.isObjectLiteralExpression(definition))
452
+ continue;
453
+ const property = definition.properties.find(candidate => (ts.isPropertyAssignment(candidate) || ts.isShorthandPropertyAssignment(candidate)) &&
454
+ (ts.isIdentifier(candidate.name) || ts.isStringLiteral(candidate.name)) && candidate.name.text === 'aliasHosts');
455
+ if (!property)
456
+ continue;
457
+ const array = ts.isPropertyAssignment(property)
458
+ ? arrayLiteral(file, property.initializer)
459
+ : ts.isShorthandPropertyAssignment(property) ? arrayLiteral(file, property.name) : undefined;
460
+ if (array && array.elements.length === 0)
461
+ continue;
462
+ return lineOfNode(file, property);
463
+ }
464
+ return undefined;
465
+ }
301
466
  /** Lines of `export ... from` statements and exports of names this module only imported. */
302
467
  function forwardingExports(file) {
303
468
  const source = parseBrandlabSourceFile(file);
@@ -464,7 +629,17 @@ function inspectDelivery(root, add) {
464
629
  installCommand = undefined;
465
630
  }
466
631
  if (installCommand !== 'npm ci')
467
- add('vercel.json', 'vercel-install', 'vercel.json sets installCommand to "npm ci" (keep any existing regions).');
632
+ add('vercel.json', 'vercel-install', 'vercel.json sets installCommand to "npm ci".');
633
+ let regions;
634
+ try {
635
+ regions = vercel === null ? undefined : JSON.parse(vercel).regions;
636
+ }
637
+ catch {
638
+ regions = undefined;
639
+ }
640
+ if (!Array.isArray(regions) || regions.length !== 1 || regions[0] !== BRANDLAB_FUNCTION_REGION) {
641
+ add('vercel.json', 'vercel-region', `vercel.json runs the server functions in Sydney: "regions": ["${BRANDLAB_FUNCTION_REGION}"] (found ${regions === undefined ? 'no regions' : JSON.stringify(regions)}). The visitors are in Australia; a Washington (iad1) function adds a Pacific round trip to every request it serves.`);
642
+ }
468
643
  if (!existsSync(join(root, 'README.md')))
469
644
  add('README.md', 'project-docs', 'Add the generated README.md (commands, architecture, environment names, deployment and rollback).');
470
645
  const agents = join(root, 'AGENTS.md');
@@ -476,6 +651,21 @@ function inspectDelivery(root, add) {
476
651
  if (claude === null || claude.trim() !== '@AGENTS.md')
477
652
  add('CLAUDE.md', 'project-docs', 'CLAUDE.md is the single line @AGENTS.md.');
478
653
  }
654
+ const directoryListings = new Map();
655
+ /** The exact file names in a directory (cached per check run). */
656
+ function directoryEntries(path) {
657
+ let entries = directoryListings.get(path);
658
+ if (!entries) {
659
+ try {
660
+ entries = new Set(readdirSync(path));
661
+ }
662
+ catch {
663
+ entries = new Set();
664
+ }
665
+ directoryListings.set(path, entries);
666
+ }
667
+ return entries;
668
+ }
479
669
  function readOptional(path) {
480
670
  return existsSync(path) ? readFileSync(path, 'utf8') : null;
481
671
  }