@brandlab-digital/conformance 2.1.0 → 3.1.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.
Files changed (67) hide show
  1. package/dist/analytics-e2e.d.ts +52 -0
  2. package/dist/analytics-e2e.d.ts.map +1 -0
  3. package/dist/analytics-e2e.js +189 -0
  4. package/dist/analytics-e2e.js.map +1 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +40 -3
  8. package/dist/cli.js.map +1 -1
  9. package/dist/contact-e2e.d.ts +43 -0
  10. package/dist/contact-e2e.d.ts.map +1 -0
  11. package/dist/contact-e2e.js +193 -0
  12. package/dist/contact-e2e.js.map +1 -0
  13. package/dist/current-release.js +7 -7
  14. package/dist/current-release.js.map +1 -1
  15. package/dist/e2e-support.d.ts +42 -0
  16. package/dist/e2e-support.d.ts.map +1 -0
  17. package/dist/e2e-support.js +126 -0
  18. package/dist/e2e-support.js.map +1 -0
  19. package/dist/engineering.d.ts +14 -0
  20. package/dist/engineering.d.ts.map +1 -1
  21. package/dist/engineering.js +446 -50
  22. package/dist/engineering.js.map +1 -1
  23. package/dist/estate.d.ts +105 -0
  24. package/dist/estate.d.ts.map +1 -0
  25. package/dist/estate.js +391 -0
  26. package/dist/estate.js.map +1 -0
  27. package/dist/families-e2e.d.ts +45 -0
  28. package/dist/families-e2e.d.ts.map +1 -0
  29. package/dist/families-e2e.js +248 -0
  30. package/dist/families-e2e.js.map +1 -0
  31. package/dist/glob.d.ts +21 -0
  32. package/dist/glob.d.ts.map +1 -0
  33. package/dist/glob.js +138 -0
  34. package/dist/glob.js.map +1 -0
  35. package/dist/knip.d.ts +47 -0
  36. package/dist/knip.d.ts.map +1 -0
  37. package/dist/knip.js +84 -0
  38. package/dist/knip.js.map +1 -0
  39. package/dist/project.d.ts +4 -1
  40. package/dist/project.d.ts.map +1 -1
  41. package/dist/project.js +163 -147
  42. package/dist/project.js.map +1 -1
  43. package/dist/public-copy.d.ts +36 -0
  44. package/dist/public-copy.d.ts.map +1 -0
  45. package/dist/public-copy.js +517 -0
  46. package/dist/public-copy.js.map +1 -0
  47. package/dist/rule-registry.d.ts +292 -0
  48. package/dist/rule-registry.d.ts.map +1 -0
  49. package/dist/rule-registry.js +68 -0
  50. package/dist/rule-registry.js.map +1 -0
  51. package/dist/search-certification.d.ts +4 -0
  52. package/dist/search-certification.d.ts.map +1 -1
  53. package/dist/search-certification.js +17 -3
  54. package/dist/search-certification.js.map +1 -1
  55. package/dist/source-files.d.ts +54 -0
  56. package/dist/source-files.d.ts.map +1 -0
  57. package/dist/source-files.js +323 -0
  58. package/dist/source-files.js.map +1 -0
  59. package/dist/static-rules.d.ts +45 -0
  60. package/dist/static-rules.d.ts.map +1 -0
  61. package/dist/static-rules.js +762 -0
  62. package/dist/static-rules.js.map +1 -0
  63. package/dist/verification-policy.d.ts +13 -4
  64. package/dist/verification-policy.d.ts.map +1 -1
  65. package/dist/verification-policy.js +31 -12
  66. package/dist/verification-policy.js.map +1 -1
  67. package/package.json +37 -1
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The shared contact-form journeys for a Brandlab production build. The contact
3
+ * endpoint is mocked; every other request leaves the browser only as a GET or
4
+ * HEAD to the build under test, and anything else is aborted (fail closed), so
5
+ * no enquiry or email is ever sent, even if the form posts somewhere unexpected.
6
+ *
7
+ * // tests/e2e/contact.spec.ts
8
+ * import { defineBrandlabContactTests } from '@brandlab-digital/conformance/contact-e2e'
9
+ *
10
+ * defineBrandlabContactTests({
11
+ * path: '/contact',
12
+ * fields: { name: 'Test Visitor', email: 'visitor@example.com', phone: '0400 000 000', message: 'Blocked kitchen drain.' },
13
+ * submit: 'Send enquiry',
14
+ * photos: true,
15
+ * })
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { expect, test } from '@playwright/test';
19
+ import { ACCEPTED_CONTACT_RESULT, RETRYABLE_CONTACT_FAILURE, blockedSubmissions, contactEndpointMatcher, createTestPhoto, isolateFromNetwork, readContactSubmission, } from './e2e-support.js';
20
+ const INVALID_HEIC = { name: 'invalid.heic', mimeType: 'image/heic', buffer: Buffer.from('invalid HEIC test bytes') };
21
+ export function defineBrandlabContactTests(options) {
22
+ const matchesEndpoint = contactEndpointMatcher(options.endpoint);
23
+ const photos = options.photos ? (options.photos === true ? {} : options.photos) : null;
24
+ const removeButton = photos?.removeButton ?? /^Remove /;
25
+ const photo = photos?.file
26
+ ? { name: photos.file.split(/[\\/]/).at(-1), mimeType: mimeTypeOf(photos.file), buffer: readFileSync(photos.file) }
27
+ : { name: 'brandlab-contact-photo.png', mimeType: 'image/png', buffer: createTestPhoto() };
28
+ async function mockEndpoint(page, responses) {
29
+ const requests = [];
30
+ await page.route(matchesEndpoint, async (route) => {
31
+ requests.push(route.request());
32
+ const outcome = responses[Math.min(requests.length, responses.length) - 1];
33
+ await route.fulfill(outcome === 'success'
34
+ ? { status: 200, json: ACCEPTED_CONTACT_RESULT }
35
+ : { status: 503, json: RETRYABLE_CONTACT_FAILURE });
36
+ });
37
+ return requests;
38
+ }
39
+ async function addPhoto(page) {
40
+ const form = contactForm(page, options);
41
+ await form.locator('input[type="file"]').setInputFiles(photo);
42
+ await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1, { timeout: 30_000 });
43
+ }
44
+ test.describe('Brandlab contact form', () => {
45
+ // Service workers could answer requests outside page and context routing.
46
+ test.use({ serviceWorkers: 'block' });
47
+ test.beforeEach(async ({ page, baseURL }) => { await isolateFromNetwork(page, { baseURL }); });
48
+ test('cannot be submitted natively before hydration', async ({ page }) => {
49
+ const requests = await mockEndpoint(page, ['success']);
50
+ let releaseScripts;
51
+ const scriptsReleased = new Promise((resolve) => { releaseScripts = resolve; });
52
+ await page.route('**/_next/static/**/*.js', async (route) => { await scriptsReleased; await route.fallback(); });
53
+ await page.goto(options.path, { waitUntil: 'domcontentloaded' });
54
+ const nativeSubmissions = [];
55
+ page.on('request', (request) => {
56
+ if (request.isNavigationRequest() && request.method() !== 'GET')
57
+ nativeSubmissions.push(request.url());
58
+ });
59
+ const form = contactForm(page, options);
60
+ await expect(form).toHaveAttribute('method', /^post$/i);
61
+ expect(await form.getAttribute('action'), 'the form must not post natively to another URL').toBeNull();
62
+ const submit = form.getByRole('button', { name: options.submit, exact: typeof options.submit === 'string' });
63
+ await expect(submit).toBeDisabled();
64
+ const [firstField] = Object.keys(options.fields);
65
+ if (firstField) {
66
+ await form.locator(`[name="${firstField}"]`).first().press('Enter');
67
+ await page.waitForTimeout(300);
68
+ }
69
+ expect(new URL(page.url()).search, 'a native submission added the enquiry to the URL').toBe('');
70
+ expect(nativeSubmissions, 'the form submitted natively before hydration').toEqual([]);
71
+ expect(requests).toHaveLength(0);
72
+ releaseScripts();
73
+ await expect(submit).toBeEnabled({ timeout: 15_000 });
74
+ });
75
+ test('a delivery failure keeps every field and prepared photo', async ({ page }) => {
76
+ const requests = await mockEndpoint(page, ['failure']);
77
+ await fillBrandlabContactForm(page, options);
78
+ if (photos)
79
+ await addPhoto(page);
80
+ await submitButton(page, options).click();
81
+ await expectContactRequests(page, requests, 1);
82
+ const form = contactForm(page, options);
83
+ await expect(form.getByRole('button', { name: options.retry ?? options.submit, exact: typeof (options.retry ?? options.submit) === 'string' })).toBeVisible();
84
+ await expectFieldValues(form, options.fields);
85
+ if (photos)
86
+ await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1);
87
+ });
88
+ if (photos) {
89
+ test('an unreadable HEIC photo leaves the enquiry and prepared photos intact', async ({ page }) => {
90
+ await mockEndpoint(page, ['failure']);
91
+ await fillBrandlabContactForm(page, options);
92
+ await addPhoto(page);
93
+ const form = contactForm(page, options);
94
+ await form.locator('input[type="file"]').setInputFiles(INVALID_HEIC);
95
+ await expect(page.getByText(photos.invalidMessage ?? /could not prepare this photo/i).first()).toBeVisible({ timeout: 30_000 });
96
+ await expectFieldValues(form, options.fields);
97
+ await expect(form.getByRole('button', { name: removeButton })).toHaveCount(1);
98
+ });
99
+ }
100
+ test('retrying after a failure resends the same submission', async ({ page }) => {
101
+ const requests = await mockEndpoint(page, ['failure', 'success']);
102
+ await fillBrandlabContactForm(page, options);
103
+ if (photos)
104
+ await addPhoto(page);
105
+ await submitButton(page, options).click();
106
+ await expectContactRequests(page, requests, 1);
107
+ const retry = contactForm(page, options).getByRole('button', { name: options.retry ?? options.submit, exact: typeof (options.retry ?? options.submit) === 'string' });
108
+ await retry.click();
109
+ await expectContactRequests(page, requests, 2);
110
+ const [first, second] = await Promise.all(requests.map(readContactSubmission));
111
+ expect(first.fields.clientSubmissionId, 'the submission carries a client submission ID').toBeTruthy();
112
+ expect(second.fields.clientSubmissionId).toBe(first.fields.clientSubmissionId);
113
+ for (const [name, value] of Object.entries(options.fields))
114
+ expect(second.fields[name], name).toBe(value);
115
+ expect(second.attachments).toEqual(first.attachments);
116
+ if (photos)
117
+ expect(first.attachments).toHaveLength(1);
118
+ });
119
+ test('an accepted enquiry is sent once and moves to the thank-you page', async ({ page }) => {
120
+ const requests = await mockEndpoint(page, ['success']);
121
+ await fillBrandlabContactForm(page, options);
122
+ await submitButton(page, options).click();
123
+ await expect(page).toHaveURL(successPattern(options.successPath));
124
+ expect(requests).toHaveLength(1);
125
+ });
126
+ });
127
+ }
128
+ /**
129
+ * Open the contact page, wait for hydration and enter every field value. The page
130
+ * is isolated first, so submitting without a mock aborts the request rather than
131
+ * delivering an enquiry.
132
+ */
133
+ export async function fillBrandlabContactForm(page, journey) {
134
+ await isolateFromNetwork(page);
135
+ if (!new URL(page.url(), 'http://brandlab.invalid').pathname.endsWith(journey.path))
136
+ await page.goto(journey.path);
137
+ // The shared form keeps submit disabled until React has hydrated its handlers.
138
+ await expect(submitButton(page, journey)).toBeEnabled({ timeout: 30_000 });
139
+ const form = contactForm(page, journey);
140
+ for (const [name, value] of Object.entries(journey.fields)) {
141
+ const control = form.locator(`[name="${name}"]`).first();
142
+ const tag = await control.evaluate((element) => element.tagName);
143
+ const type = await control.getAttribute('type');
144
+ if (tag === 'SELECT')
145
+ await control.selectOption(value);
146
+ else if (type === 'checkbox' || type === 'radio')
147
+ await control.check();
148
+ else
149
+ await control.fill(value);
150
+ }
151
+ }
152
+ export function successPattern(successPath = '/thank-you') {
153
+ return typeof successPath === 'string' ? new RegExp(`${successPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[?#].*)?$`) : successPath;
154
+ }
155
+ /** Wait for mocked contact requests, naming any submission the guard blocked instead. */
156
+ export async function expectContactRequests(page, requests, count) {
157
+ try {
158
+ await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(count);
159
+ }
160
+ catch (error) {
161
+ const blocked = blockedSubmissions(page);
162
+ throw new Error(blocked.length > 0
163
+ ? `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.`
164
+ : `Expected ${count} request(s) to the mocked contact endpoint; ${requests.length} arrived.`, { cause: error });
165
+ }
166
+ }
167
+ function contactForm(page, journey) {
168
+ if (journey.form)
169
+ return page.locator(journey.form).first();
170
+ const [firstField] = Object.keys(journey.fields);
171
+ return page.locator('form').filter({ has: page.locator(`[name="${firstField}"]`) }).first();
172
+ }
173
+ function submitButton(page, journey) {
174
+ return contactForm(page, journey).getByRole('button', { name: journey.submit, exact: typeof journey.submit === 'string' });
175
+ }
176
+ async function expectFieldValues(form, fields) {
177
+ for (const [name, value] of Object.entries(fields)) {
178
+ const control = form.locator(`[name="${name}"]`).first();
179
+ const type = await control.getAttribute('type');
180
+ if (type === 'checkbox' || type === 'radio')
181
+ await expect(control, name).toBeChecked();
182
+ else
183
+ await expect(control, name).toHaveValue(value);
184
+ }
185
+ }
186
+ function mimeTypeOf(file) {
187
+ const extension = file.split('.').at(-1)?.toLowerCase();
188
+ return extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'
189
+ : extension === 'webp' ? 'image/webp'
190
+ : extension === 'heic' ? 'image/heic'
191
+ : 'image/png';
192
+ }
193
+ //# sourceMappingURL=contact-e2e.js.map
@@ -0,0 +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"]}
@@ -15,27 +15,27 @@ export const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze({
15
15
  ],
16
16
  "contracts": {
17
17
  "analytics": "analytics-v4",
18
- "architecture": "architecture-v6",
19
- "configuration": "project-config-v6",
18
+ "architecture": "architecture-v7",
19
+ "configuration": "project-config-v7",
20
20
  "contact": "contact-v4",
21
21
  "seo": "seo-v4"
22
22
  },
23
23
  "kind": "brandlab-runtime-release-contract",
24
24
  "packages": {
25
25
  "@brandlab-digital/conformance": {
26
- "version": "2.1.0"
26
+ "version": "3.1.0"
27
27
  },
28
28
  "@brandlab-digital/eslint-plugin": {
29
- "version": "2.1.0"
29
+ "version": "3.1.0"
30
30
  },
31
31
  "@brandlab-digital/site-core": {
32
- "version": "2.1.0"
32
+ "version": "3.1.0"
33
33
  },
34
34
  "@brandlab-digital/site-next": {
35
- "version": "2.1.0"
35
+ "version": "3.1.0"
36
36
  }
37
37
  },
38
- "releaseVersion": "2.1.0",
38
+ "releaseVersion": "3.1.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-v6\",\n \"configuration\": \"project-config-v6\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"2.1.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"2.1.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"2.1.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"2.1.0\"\n }\n },\n \"releaseVersion\": \"2.1.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.1.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"3.1.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"3.1.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"3.1.0\"\n }\n },\n \"releaseVersion\": \"3.1.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"]}
@@ -0,0 +1,42 @@
1
+ import { type Page, type Request } from '@playwright/test';
2
+ /**
3
+ * Fail closed for every page in the browser context: requests to other hosts are
4
+ * aborted, and the build under test receives only GET and HEAD. Any other request
5
+ * (a form POST, a native submission, a beacon, a server action) is aborted unless a
6
+ * page-level mock fulfils it first, because page routes take precedence over this
7
+ * context route. A real enquiry therefore never reaches the contact handler or a
8
+ * provider, whatever the form's endpoint is. Safe to call repeatedly.
9
+ */
10
+ export declare function isolateFromNetwork(page: Page, options?: {
11
+ baseURL?: string;
12
+ googleTag?: 'stub';
13
+ }): Promise<void>;
14
+ /** Non-GET requests to the build under test that the guard aborted because no mock handled them. */
15
+ export declare function blockedSubmissions(page: Page): readonly string[];
16
+ /** Match the contact endpoint by pathname, so trailing-slash and query-string variants are caught. */
17
+ export declare function contactEndpointMatcher(endpoint?: string): (url: URL) => boolean;
18
+ export interface ContactSubmission {
19
+ fields: Record<string, string>;
20
+ attachments: {
21
+ field: string;
22
+ name: string;
23
+ type: string;
24
+ bytes: string;
25
+ }[];
26
+ }
27
+ /** Decode a captured multipart contact request with the platform FormData parser. */
28
+ export declare function readContactSubmission(request: Request): Promise<ContactSubmission>;
29
+ export declare const ACCEPTED_CONTACT_RESULT: Readonly<{
30
+ ok: true;
31
+ accepted: true;
32
+ trackConversion: true;
33
+ confirmationId: string;
34
+ }>;
35
+ export declare const RETRYABLE_CONTACT_FAILURE: Readonly<{
36
+ ok: false;
37
+ code: "delivery_unavailable";
38
+ retryable: true;
39
+ }>;
40
+ /** A small, valid PNG so photo journeys need no project fixture file. */
41
+ export declare function createTestPhoto(width?: number, height?: number): Buffer;
42
+ //# sourceMappingURL=e2e-support.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"e2e-support.d.ts","sourceRoot":"","sources":["../src/e2e-support.ts"],"names":[],"mappings":"AACA,OAAO,EAA6B,KAAK,IAAI,EAAE,KAAK,OAAO,EAAc,MAAM,kBAAkB,CAAC;AAQlG;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,EACV,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,iBAyBvD;AAED,oGAAoG;AACpG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,IAAI,GAAG,SAAS,MAAM,EAAE,CAEhE;AAED,sGAAsG;AACtG,wBAAgB,sBAAsB,CAAC,QAAQ,SAAiB,GAAG,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAGvF;AAgBD,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7E;AAED,qFAAqF;AACrF,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAcxF;AAED,eAAO,MAAM,uBAAuB;;;;;EAKlC,CAAC;AAEH,eAAO,MAAM,yBAAyB;;;;EAIpC,CAAC;AAEH,yEAAyE;AACzE,wBAAgB,eAAe,CAAC,KAAK,SAAK,EAAE,MAAM,SAAK,GAAG,MAAM,CAc/D"}
@@ -0,0 +1,126 @@
1
+ import { deflateSync } from 'node:zlib';
2
+ import { test } from '@playwright/test';
3
+ /** Hosts that stand for the production build under test. */
4
+ const LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
5
+ const READ_METHODS = new Set(['GET', 'HEAD']);
6
+ const guardedContexts = new WeakMap();
7
+ const stubbedPages = new WeakSet();
8
+ /**
9
+ * Fail closed for every page in the browser context: requests to other hosts are
10
+ * aborted, and the build under test receives only GET and HEAD. Any other request
11
+ * (a form POST, a native submission, a beacon, a server action) is aborted unless a
12
+ * page-level mock fulfils it first, because page routes take precedence over this
13
+ * context route. A real enquiry therefore never reaches the contact handler or a
14
+ * provider, whatever the form's endpoint is. Safe to call repeatedly.
15
+ */
16
+ export async function isolateFromNetwork(page, options = {}) {
17
+ const context = page.context();
18
+ if (!guardedContexts.has(context)) {
19
+ const blocked = [];
20
+ guardedContexts.set(context, blocked);
21
+ const baseURL = options.baseURL ?? currentBaseURL();
22
+ const baseHost = baseURL ? new URL(baseURL).host : null;
23
+ await context.route('**/*', (route) => {
24
+ const request = route.request();
25
+ const url = new URL(request.url());
26
+ if (url.protocol === 'data:' || url.protocol === 'blob:')
27
+ return route.fallback();
28
+ const local = LOCAL_HOSTS.has(url.hostname) || url.host === baseHost;
29
+ if (local && READ_METHODS.has(request.method()))
30
+ return route.fallback();
31
+ if (local)
32
+ blocked.push(`${request.method()} ${url.pathname}${url.search}`);
33
+ return route.abort('blockedbyclient');
34
+ });
35
+ }
36
+ if (options.googleTag === 'stub' && !stubbedPages.has(page)) {
37
+ stubbedPages.add(page);
38
+ await page.route((url) => url.hostname === 'www.googletagmanager.com' && url.pathname === '/gtag/js', (route) => route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }));
39
+ }
40
+ }
41
+ /** Non-GET requests to the build under test that the guard aborted because no mock handled them. */
42
+ export function blockedSubmissions(page) {
43
+ return guardedContexts.get(page.context()) ?? [];
44
+ }
45
+ /** Match the contact endpoint by pathname, so trailing-slash and query-string variants are caught. */
46
+ export function contactEndpointMatcher(endpoint = '/api/contact') {
47
+ const target = normaliseEndpointPath(endpoint);
48
+ return (url) => (url.pathname.replace(/\/+$/, '') || '/') === target;
49
+ }
50
+ function normaliseEndpointPath(endpoint) {
51
+ const pathname = endpoint.trim().replace(/^\*\*(?=\/)/, '').split(/[?#]/)[0] ?? '';
52
+ return `/${pathname.replace(/^\/+/, '').replace(/\/+$/, '')}`;
53
+ }
54
+ function currentBaseURL() {
55
+ try {
56
+ const baseURL = test.info().project.use.baseURL;
57
+ return typeof baseURL === 'string' ? baseURL : undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ /** Decode a captured multipart contact request with the platform FormData parser. */
64
+ export async function readContactSubmission(request) {
65
+ const body = request.postDataBuffer() ?? Buffer.alloc(0);
66
+ const form = await new globalThis.Request('http://brandlab.invalid/api/contact', {
67
+ method: 'POST',
68
+ headers: { 'content-type': request.headers()['content-type'] ?? '' },
69
+ body: new Uint8Array(body),
70
+ }).formData();
71
+ const fields = {};
72
+ const attachments = [];
73
+ for (const [field, value] of form.entries()) {
74
+ if (typeof value === 'string')
75
+ fields[field] = value;
76
+ else
77
+ attachments.push({ field, name: value.name, type: value.type, bytes: Buffer.from(await value.arrayBuffer()).toString('base64') });
78
+ }
79
+ return { fields, attachments };
80
+ }
81
+ export const ACCEPTED_CONTACT_RESULT = Object.freeze({
82
+ ok: true,
83
+ accepted: true,
84
+ trackConversion: true,
85
+ confirmationId: 'b'.repeat(64),
86
+ });
87
+ export const RETRYABLE_CONTACT_FAILURE = Object.freeze({
88
+ ok: false,
89
+ code: 'delivery_unavailable',
90
+ retryable: true,
91
+ });
92
+ /** A small, valid PNG so photo journeys need no project fixture file. */
93
+ export function createTestPhoto(width = 48, height = 32) {
94
+ const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(width * 3, 0)]);
95
+ for (let x = 0; x < width; x += 1)
96
+ row.set([0x2f, 0x6f, 0xb3], 1 + x * 3);
97
+ const raw = Buffer.concat(Array.from({ length: height }, () => row));
98
+ const header = Buffer.alloc(13);
99
+ header.writeUInt32BE(width, 0);
100
+ header.writeUInt32BE(height, 4);
101
+ header.set([8, 2, 0, 0, 0], 8);
102
+ return Buffer.concat([
103
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
104
+ pngChunk('IHDR', header),
105
+ pngChunk('IDAT', deflateSync(raw)),
106
+ pngChunk('IEND', Buffer.alloc(0)),
107
+ ]);
108
+ }
109
+ function pngChunk(type, data) {
110
+ const length = Buffer.alloc(4);
111
+ length.writeUInt32BE(data.length);
112
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
113
+ const crc = Buffer.alloc(4);
114
+ crc.writeUInt32BE(crc32(body));
115
+ return Buffer.concat([length, body, crc]);
116
+ }
117
+ function crc32(bytes) {
118
+ let crc = 0xffffffff;
119
+ for (const byte of bytes) {
120
+ crc ^= byte;
121
+ for (let bit = 0; bit < 8; bit += 1)
122
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
123
+ }
124
+ return (crc ^ 0xffffffff) >>> 0;
125
+ }
126
+ //# sourceMappingURL=e2e-support.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"e2e-support.js","sourceRoot":"","sources":["../src/e2e-support.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,IAAI,EAA4D,MAAM,kBAAkB,CAAC;AAElG,4DAA4D;AAC5D,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC;AACjE,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;AAC9C,MAAM,eAAe,GAAG,IAAI,OAAO,EAA4B,CAAC;AAChE,MAAM,YAAY,GAAG,IAAI,OAAO,EAAQ,CAAC;AAEzC;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,IAAU,EACV,UAAoD,EAAE;IAEtD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;IAC/B,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;QAClC,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,eAAe,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACtC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,cAAc,EAAE,CAAC;QACpD,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;QACxD,MAAM,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,KAAY,EAAE,EAAE;YAC3C,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC;YAChC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;YACnC,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;gBAAE,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAC;YAClF,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC;YACrE,IAAI,KAAK,IAAI,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;gBAAE,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAC;YACzE,IAAI,KAAK;gBAAE,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,IAAI,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;YAC5E,OAAO,KAAK,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;QACxC,CAAC,CAAC,CAAC;IACL,CAAC;IACD,IAAI,OAAO,CAAC,SAAS,KAAK,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;QAC5D,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACvB,MAAM,IAAI,CAAC,KAAK,CACd,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,KAAK,0BAA0B,IAAI,GAAG,CAAC,QAAQ,KAAK,UAAU,EACnF,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,wBAAwB,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAC3F,CAAC;IACJ,CAAC;AACH,CAAC;AAED,oGAAoG;AACpG,MAAM,UAAU,kBAAkB,CAAC,IAAU;IAC3C,OAAO,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC;AACnD,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,sBAAsB,CAAC,QAAQ,GAAG,cAAc;IAC9D,MAAM,MAAM,GAAG,qBAAqB,CAAC,QAAQ,CAAC,CAAC;IAC/C,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,KAAK,MAAM,CAAC;AACvE,CAAC;AAED,SAAS,qBAAqB,CAAC,QAAgB;IAC7C,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACnF,OAAO,IAAI,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,CAAC;AAChE,CAAC;AAED,SAAS,cAAc;IACrB,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;QAChD,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAOD,qFAAqF;AACrF,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,OAAgB;IAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,cAAc,EAAE,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACzD,MAAM,IAAI,GAAG,MAAM,IAAI,UAAU,CAAC,OAAO,CAAC,qCAAqC,EAAE;QAC/E,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,cAAc,CAAC,IAAI,EAAE,EAAE;QACpE,IAAI,EAAE,IAAI,UAAU,CAAC,IAAI,CAAC;KAC3B,CAAC,CAAC,QAAQ,EAAE,CAAC;IACd,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,MAAM,WAAW,GAAqC,EAAE,CAAC;IACzD,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC;QAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC;;YAChD,WAAW,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACzI,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;AACjC,CAAC;AAED,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC,MAAM,CAAC;IACnD,EAAE,EAAE,IAAI;IACR,QAAQ,EAAE,IAAI;IACd,eAAe,EAAE,IAAI;IACrB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;CAC/B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,yBAAyB,GAAG,MAAM,CAAC,MAAM,CAAC;IACrD,EAAE,EAAE,KAAK;IACT,IAAI,EAAE,sBAAsB;IAC5B,SAAS,EAAE,IAAI;CAChB,CAAC,CAAC;AAEH,yEAAyE;AACzE,MAAM,UAAU,eAAe,CAAC,KAAK,GAAG,EAAE,EAAE,MAAM,GAAG,EAAE;IACrD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1E,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,IAAI,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1E,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACrE,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAChC,MAAM,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/B,MAAM,CAAC,aAAa,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAChC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC/B,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,CAAC,CAAC;QAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;KAClC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY,EAAE,IAAY;IAC1C,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC/B,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;IAC/D,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC5B,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAC/B,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;AAC5C,CAAC;AAED,SAAS,KAAK,CAAC,KAAa;IAC1B,IAAI,GAAG,GAAG,UAAU,CAAC;IACrB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,GAAG,IAAI,IAAI,CAAC;QACZ,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC;YAAE,GAAG,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,UAAU,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;IACrF,CAAC;IACD,OAAO,CAAC,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;AAClC,CAAC","sourcesContent":["import { deflateSync } from 'node:zlib';\nimport { test, type BrowserContext, type Page, type Request, type Route } from '@playwright/test';\n\n/** Hosts that stand for the production build under test. */\nconst LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\nconst READ_METHODS = new Set(['GET', 'HEAD']);\nconst guardedContexts = new WeakMap<BrowserContext, string[]>();\nconst stubbedPages = new WeakSet<Page>();\n\n/**\n * Fail closed for every page in the browser context: requests to other hosts are\n * aborted, and the build under test receives only GET and HEAD. Any other request\n * (a form POST, a native submission, a beacon, a server action) is aborted unless a\n * page-level mock fulfils it first, because page routes take precedence over this\n * context route. A real enquiry therefore never reaches the contact handler or a\n * provider, whatever the form's endpoint is. Safe to call repeatedly.\n */\nexport async function isolateFromNetwork(\n page: Page,\n options: { baseURL?: string; googleTag?: 'stub' } = {},\n) {\n const context = page.context();\n if (!guardedContexts.has(context)) {\n const blocked: string[] = [];\n guardedContexts.set(context, blocked);\n const baseURL = options.baseURL ?? currentBaseURL();\n const baseHost = baseURL ? new URL(baseURL).host : null;\n await context.route('**/*', (route: Route) => {\n const request = route.request();\n const url = new URL(request.url());\n if (url.protocol === 'data:' || url.protocol === 'blob:') return route.fallback();\n const local = LOCAL_HOSTS.has(url.hostname) || url.host === baseHost;\n if (local && READ_METHODS.has(request.method())) return route.fallback();\n if (local) blocked.push(`${request.method()} ${url.pathname}${url.search}`);\n return route.abort('blockedbyclient');\n });\n }\n if (options.googleTag === 'stub' && !stubbedPages.has(page)) {\n stubbedPages.add(page);\n await page.route(\n (url) => url.hostname === 'www.googletagmanager.com' && url.pathname === '/gtag/js',\n (route) => route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),\n );\n }\n}\n\n/** Non-GET requests to the build under test that the guard aborted because no mock handled them. */\nexport function blockedSubmissions(page: Page): readonly string[] {\n return guardedContexts.get(page.context()) ?? [];\n}\n\n/** Match the contact endpoint by pathname, so trailing-slash and query-string variants are caught. */\nexport function contactEndpointMatcher(endpoint = '/api/contact'): (url: URL) => boolean {\n const target = normaliseEndpointPath(endpoint);\n return (url) => (url.pathname.replace(/\\/+$/, '') || '/') === target;\n}\n\nfunction normaliseEndpointPath(endpoint: string): string {\n const pathname = endpoint.trim().replace(/^\\*\\*(?=\\/)/, '').split(/[?#]/)[0] ?? '';\n return `/${pathname.replace(/^\\/+/, '').replace(/\\/+$/, '')}`;\n}\n\nfunction currentBaseURL(): string | undefined {\n try {\n const baseURL = test.info().project.use.baseURL;\n return typeof baseURL === 'string' ? baseURL : undefined;\n } catch {\n return undefined;\n }\n}\n\nexport interface ContactSubmission {\n fields: Record<string, string>;\n attachments: { field: string; name: string; type: string; bytes: string }[];\n}\n\n/** Decode a captured multipart contact request with the platform FormData parser. */\nexport async function readContactSubmission(request: Request): Promise<ContactSubmission> {\n const body = request.postDataBuffer() ?? Buffer.alloc(0);\n const form = await new globalThis.Request('http://brandlab.invalid/api/contact', {\n method: 'POST',\n headers: { 'content-type': request.headers()['content-type'] ?? '' },\n body: new Uint8Array(body),\n }).formData();\n const fields: Record<string, string> = {};\n const attachments: ContactSubmission['attachments'] = [];\n for (const [field, value] of form.entries()) {\n if (typeof value === 'string') fields[field] = value;\n else attachments.push({ field, name: value.name, type: value.type, bytes: Buffer.from(await value.arrayBuffer()).toString('base64') });\n }\n return { fields, attachments };\n}\n\nexport const ACCEPTED_CONTACT_RESULT = Object.freeze({\n ok: true,\n accepted: true,\n trackConversion: true,\n confirmationId: 'b'.repeat(64),\n});\n\nexport const RETRYABLE_CONTACT_FAILURE = Object.freeze({\n ok: false,\n code: 'delivery_unavailable',\n retryable: true,\n});\n\n/** A small, valid PNG so photo journeys need no project fixture file. */\nexport function createTestPhoto(width = 48, height = 32): Buffer {\n const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(width * 3, 0)]);\n for (let x = 0; x < width; x += 1) row.set([0x2f, 0x6f, 0xb3], 1 + x * 3);\n const raw = Buffer.concat(Array.from({ length: height }, () => row));\n const header = Buffer.alloc(13);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n header.set([8, 2, 0, 0, 0], 8);\n return Buffer.concat([\n Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),\n pngChunk('IHDR', header),\n pngChunk('IDAT', deflateSync(raw)),\n pngChunk('IEND', Buffer.alloc(0)),\n ]);\n}\n\nfunction pngChunk(type: string, data: Buffer): Buffer {\n const length = Buffer.alloc(4);\n length.writeUInt32BE(data.length);\n const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);\n const crc = Buffer.alloc(4);\n crc.writeUInt32BE(crc32(body));\n return Buffer.concat([length, body, crc]);\n}\n\nfunction crc32(bytes: Buffer): number {\n let crc = 0xffffffff;\n for (const byte of bytes) {\n crc ^= byte;\n for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));\n }\n return (crc ^ 0xffffffff) >>> 0;\n}\n"]}
@@ -1,8 +1,15 @@
1
1
  export declare const ENGINEERING_FAST_COMMANDS: readonly ["typecheck", "lint", "test", "check:unused", "check:unused:production", "check:standards"];
2
+ /** The exact fast verification command from the Brandlab 3.0 target (section 6). */
3
+ export declare const BRANDLAB_EXPECTED_VERIFY_SCRIPT = "npm run check:standards && npm run typecheck && npm run lint && npm run test && npm run check:unused && npm run check:unused:production";
4
+ /** Scripts whose exact values the 3.0 target fixes; dev and start must exist. */
5
+ export declare const BRANDLAB_EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
6
+ export type EngineeringFindingSeverity = 'blocker' | 'info';
2
7
  export interface EngineeringFinding {
3
8
  path: string;
4
9
  rule: string;
5
10
  message: string;
11
+ severity: EngineeringFindingSeverity;
12
+ line?: number;
6
13
  }
7
14
  export interface EngineeringCheck {
8
15
  status: 'pass' | 'blocked';
@@ -12,4 +19,11 @@ export interface EngineeringCheck {
12
19
  }
13
20
  /** Opt-in structural check. Static success must never masquerade as whole-project acceptance. */
14
21
  export declare function checkEngineeringStructure(root: string): EngineeringCheck;
22
+ /** Split a shell command on its chaining operators. */
23
+ export declare function splitCommandChain(command: string): string[];
24
+ /**
25
+ * The package scripts one command segment runs, or null when the segment is
26
+ * not a recognised script invocation (npm run, npm test, run-s, run-p, npm-run-all).
27
+ */
28
+ export declare function scriptReferences(segment: string, scripts: Readonly<Record<string, string>>): string[] | null;
15
29
  //# sourceMappingURL=engineering.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"engineering.d.ts","sourceRoot":"","sources":["../src/engineering.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,yBAAyB,sGAAuG,CAAC;AAE9I,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AACnF,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;AAED,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CA4DxE"}
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"}