@akinon/pz-theme 2.0.43-rc.0 → 2.0.44

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 (37) hide show
  1. package/CHANGELOG.md +14 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/checkout-pieces.test.tsx +420 -0
  4. package/src/__tests__/checkout-slot-coverage.test.ts +181 -0
  5. package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
  6. package/src/__tests__/code-owned-region.test.tsx +30 -6
  7. package/src/__tests__/composable-region.test.tsx +481 -0
  8. package/src/__tests__/part-styles.test.ts +24 -4
  9. package/src/__tests__/registry-parity.test.ts +3 -0
  10. package/src/__tests__/route-slug.test.ts +11 -0
  11. package/src/blocks/__tests__/checkout-hosted-parts.test.ts +203 -0
  12. package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
  13. package/src/blocks/block-renderer-registry.tsx +6 -0
  14. package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
  15. package/src/blocks/checkout-outlet-base.tsx +241 -0
  16. package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
  17. package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
  18. package/src/chrome/checkout-piece.tsx +145 -0
  19. package/src/chrome/checkout-pieces-provider.tsx +28 -0
  20. package/src/chrome/checkout-pieces.ts +173 -0
  21. package/src/chrome/chrome-fallback.tsx +8 -26
  22. package/src/chrome/chrome-slot.tsx +25 -26
  23. package/src/chrome/code-owned-region.tsx +21 -9
  24. package/src/chrome/composable-region-client.tsx +228 -0
  25. package/src/chrome/composable-region.tsx +58 -0
  26. package/src/chrome/composition-source.ts +34 -0
  27. package/src/chrome/composition-state.ts +49 -0
  28. package/src/chrome/live-checkout-page-context.tsx +3 -2
  29. package/src/chrome/placeholder-slugs.ts +14 -4
  30. package/src/chrome/route-slug.ts +6 -3
  31. package/src/hooks/use-designer-features.tsx +41 -10
  32. package/src/index.ts +6 -0
  33. package/src/utils/__tests__/checkout-safety.test.ts +374 -0
  34. package/src/utils/checkout-safety.ts +177 -0
  35. package/src/utils/index.ts +47 -8
  36. package/src/utils/part-styles.ts +4 -0
  37. package/src/utils/visibility-rules.ts +31 -8
package/CHANGELOG.md CHANGED
@@ -1,36 +1,19 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.43-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea23]
8
- - Updated dependencies [324f97d5]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b2]
32
- - @akinon/next@2.0.43-rc.0
33
- - @akinon/theme-contract@2.0.43-rc.0
3
+ ## 2.0.44
4
+
5
+ ### Patch Changes
6
+
7
+ - @akinon/next@2.0.44
8
+ - @akinon/theme-contract@2.0.44
9
+
10
+ ## 2.0.43
11
+
12
+ ### Patch Changes
13
+
14
+ - 0e384d73c: ZERO-4730: Make the checkout page composable with a checkout pieces bridge, outlet blocks and a body takeover region
15
+ - @akinon/next@2.0.43
16
+ - @akinon/theme-contract@2.0.43
34
17
 
35
18
  ## 2.0.42
36
19
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.43-rc.0",
4
+ "version": "2.0.44",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.43-rc.0",
11
+ "@akinon/next": "2.0.44",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.43-rc.0",
16
+ "@akinon/theme-contract": "2.0.44",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.43-rc.0",
23
+ "@akinon/next": "2.0.44",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,420 @@
1
+ import React, { useState } from 'react';
2
+ import { act, fireEvent, render, within } from '@testing-library/react';
3
+
4
+ import { CheckoutPiece } from '../chrome/checkout-piece';
5
+ import { CheckoutPiecesProvider } from '../chrome/checkout-pieces-provider';
6
+ import {
7
+ announceCheckoutPiece,
8
+ getCheckoutFlowVariantOverride,
9
+ getCheckoutOutletClaim,
10
+ isCheckoutOutletWinner,
11
+ isCheckoutPieceMounted,
12
+ isThemeEditorDesigner,
13
+ registerCheckoutOutlet,
14
+ resetCheckoutPiecesForTests,
15
+ retractCheckoutPiece,
16
+ subscribeCheckoutPieces,
17
+ unregisterCheckoutOutlet,
18
+ updateCheckoutOutletFlowVariant
19
+ } from '../chrome/checkout-pieces';
20
+
21
+ /**
22
+ * The checkout pieces bridge carries the feature's safety invariants, so each
23
+ * one is pinned here rather than eyeballed:
24
+ *
25
+ * - byte-identical uncomposed page: an unclaimed piece adds ZERO wrapper DOM;
26
+ * - one React tree: teleporting is `createPortal`, so child state survives;
27
+ * - flow never lost: a latched outlet that disappears sends the piece home,
28
+ * and a live page never latches onto an outlet that arrived late;
29
+ * - sealed reuse: outside the provider the wrapper never teleports anything.
30
+ */
31
+
32
+ const outletNodes: HTMLElement[] = [];
33
+
34
+ const createOutletNode = () => {
35
+ const node = document.createElement('div');
36
+ document.body.appendChild(node);
37
+ outletNodes.push(node);
38
+
39
+ return node;
40
+ };
41
+
42
+ beforeEach(() => resetCheckoutPiecesForTests());
43
+
44
+ afterEach(() => {
45
+ outletNodes.forEach((node) => node.remove());
46
+ outletNodes.length = 0;
47
+ });
48
+
49
+ const Counter = () => {
50
+ const [count, setCount] = useState(0);
51
+
52
+ return (
53
+ <button type="button" onClick={() => setCount((value) => value + 1)}>
54
+ count:{count}
55
+ </button>
56
+ );
57
+ };
58
+
59
+ describe('CheckoutPiece on the live site', () => {
60
+ beforeAll(() => {
61
+ // No editor handshake happened: the designer flag is unset.
62
+ delete window.__themeEditorDesigner;
63
+ });
64
+
65
+ it('adds zero DOM when nothing claims the piece', () => {
66
+ const { container } = render(
67
+ <CheckoutPiecesProvider>
68
+ <CheckoutPiece piece="flow">
69
+ <span data-x="1">hi</span>
70
+ </CheckoutPiece>
71
+ </CheckoutPiecesProvider>
72
+ );
73
+
74
+ // Exact string equality: no wrapper element, no attribute — the uncomposed
75
+ // checkout page must stay byte-identical to a build without the bridge.
76
+ expect(container.innerHTML).toBe('<span data-x="1">hi</span>');
77
+ });
78
+
79
+ it('never teleports outside the provider, even with an outlet registered', () => {
80
+ const outletNode = createOutletNode();
81
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
82
+
83
+ const { container } = render(
84
+ <CheckoutPiece piece="flow">
85
+ <span>in place</span>
86
+ </CheckoutPiece>
87
+ );
88
+
89
+ expect(container.innerHTML).toBe('<span>in place</span>');
90
+ expect(outletNode).toBeEmptyDOMElement();
91
+ });
92
+
93
+ it('teleports into an outlet registered before mount and keeps child state', () => {
94
+ const outletNode = createOutletNode();
95
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
96
+
97
+ const { container, rerender } = render(
98
+ <CheckoutPiecesProvider>
99
+ <CheckoutPiece piece="flow">
100
+ <Counter />
101
+ </CheckoutPiece>
102
+ </CheckoutPiecesProvider>
103
+ );
104
+
105
+ // Not in place — the child lives inside the outlet node.
106
+ expect(container).toBeEmptyDOMElement();
107
+
108
+ fireEvent.click(within(outletNode).getByRole('button'));
109
+ expect(within(outletNode).getByRole('button')).toHaveTextContent('count:1');
110
+
111
+ // A parent re-render must not remount the portaled child (one React tree).
112
+ rerender(
113
+ <CheckoutPiecesProvider>
114
+ <CheckoutPiece piece="flow">
115
+ <Counter />
116
+ </CheckoutPiece>
117
+ </CheckoutPiecesProvider>
118
+ );
119
+
120
+ expect(within(outletNode).getByRole('button')).toHaveTextContent('count:1');
121
+ });
122
+
123
+ it('ignores an outlet registered after the piece latched unclaimed', () => {
124
+ const { container } = render(
125
+ <CheckoutPiecesProvider>
126
+ <CheckoutPiece piece="flow">
127
+ <span>home</span>
128
+ </CheckoutPiece>
129
+ </CheckoutPiecesProvider>
130
+ );
131
+
132
+ expect(container.innerHTML).toBe('<span>home</span>');
133
+
134
+ const outletNode = createOutletNode();
135
+ act(() => {
136
+ registerCheckoutOutlet('flow', { id: 'late', node: outletNode });
137
+ });
138
+
139
+ // The unclaimed latch is permanent until navigation: a publish applies on
140
+ // the next full load, never mid-session under a payment surface.
141
+ expect(container.innerHTML).toBe('<span>home</span>');
142
+ expect(outletNode).toBeEmptyDOMElement();
143
+ });
144
+
145
+ it('returns home when the latched outlet disappears', () => {
146
+ const outletNode = createOutletNode();
147
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
148
+
149
+ const { container } = render(
150
+ <CheckoutPiecesProvider>
151
+ <CheckoutPiece piece="flow">
152
+ <span>flow</span>
153
+ </CheckoutPiece>
154
+ </CheckoutPiecesProvider>
155
+ );
156
+
157
+ expect(container).toBeEmptyDOMElement();
158
+
159
+ act(() => unregisterCheckoutOutlet('flow', 'outlet-a'));
160
+
161
+ // Ordering must never become impossible: with the outlet gone the flow
162
+ // renders in its default in-place position again.
163
+ expect(container.innerHTML).toBe('<span>flow</span>');
164
+ expect(outletNode).toBeEmptyDOMElement();
165
+ });
166
+ });
167
+
168
+ describe('CheckoutPiece in a framed but NOT edited window', () => {
169
+ // A production storefront legitimately iframed (partner portal, kiosk
170
+ // shell) is framed but never receives the editor handshake. It must keep
171
+ // live semantics: the one-shot latch, no claim-following — a portaled flow
172
+ // hosting a payment iframe must never be re-targeted mid-session.
173
+ beforeAll(() => {
174
+ delete window.__themeEditorDesigner;
175
+ Object.defineProperty(window, 'top', {
176
+ configurable: true,
177
+ value: { name: 'embedder' }
178
+ });
179
+ });
180
+
181
+ afterAll(() => {
182
+ Object.defineProperty(window, 'top', {
183
+ configurable: true,
184
+ value: window
185
+ });
186
+ });
187
+
188
+ it('ignores a late outlet: being framed is not the same as being edited', () => {
189
+ const { container } = render(
190
+ <CheckoutPiecesProvider>
191
+ <CheckoutPiece piece="flow">
192
+ <span>home</span>
193
+ </CheckoutPiece>
194
+ </CheckoutPiecesProvider>
195
+ );
196
+
197
+ expect(container.innerHTML).toBe('<span>home</span>');
198
+
199
+ const outletNode = createOutletNode();
200
+ act(() => {
201
+ registerCheckoutOutlet('flow', { id: 'late', node: outletNode });
202
+ });
203
+
204
+ expect(container.innerHTML).toBe('<span>home</span>');
205
+ expect(outletNode).toBeEmptyDOMElement();
206
+ });
207
+
208
+ it('never inherits a duplicate outlet claim when the winner dies', () => {
209
+ const first = createOutletNode();
210
+ const second = createOutletNode();
211
+ registerCheckoutOutlet('flow', { id: 'first', node: first });
212
+ registerCheckoutOutlet('flow', { id: 'second', node: second });
213
+
214
+ const { container } = render(
215
+ <CheckoutPiecesProvider>
216
+ <CheckoutPiece piece="flow">
217
+ <span>flow</span>
218
+ </CheckoutPiece>
219
+ </CheckoutPiecesProvider>
220
+ );
221
+
222
+ expect(first.innerHTML).toBe('<span>flow</span>');
223
+
224
+ // The latched outlet dies mid-session (e.g. its section errored away).
225
+ act(() => unregisterCheckoutOutlet('flow', 'first'));
226
+
227
+ // The claim passes to the duplicate in the registry, but the piece must
228
+ // come HOME, not chase it — retargeting would remount a payment iframe.
229
+ expect(container.innerHTML).toBe('<span>flow</span>');
230
+ expect(second).toBeEmptyDOMElement();
231
+ });
232
+ });
233
+
234
+ describe('CheckoutPiece in the designer', () => {
235
+ beforeAll(() => {
236
+ // The canvas is detected by the editor-proven flag, set only after a
237
+ // trusted SET_THEME_EDITOR_COOKIE through the origin-guarded bridge —
238
+ // never by mere framing.
239
+ window.__themeEditorDesigner = true;
240
+ });
241
+
242
+ afterAll(() => {
243
+ delete window.__themeEditorDesigner;
244
+ });
245
+
246
+ it('follows registration, removal and re-registration on the canvas', () => {
247
+ const { container } = render(
248
+ <CheckoutPiecesProvider>
249
+ <CheckoutPiece piece="flow">
250
+ <span>flow</span>
251
+ </CheckoutPiece>
252
+ </CheckoutPiecesProvider>
253
+ );
254
+
255
+ expect(container.innerHTML).toBe('<span>flow</span>');
256
+
257
+ const outletNode = createOutletNode();
258
+ act(() => {
259
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
260
+ });
261
+
262
+ // Late registration teleports here — the author must see the edit land.
263
+ expect(container).toBeEmptyDOMElement();
264
+ expect(outletNode.innerHTML).toBe('<span>flow</span>');
265
+
266
+ act(() => unregisterCheckoutOutlet('flow', 'outlet-a'));
267
+
268
+ expect(container.innerHTML).toBe('<span>flow</span>');
269
+ expect(outletNode).toBeEmptyDOMElement();
270
+
271
+ act(() => {
272
+ registerCheckoutOutlet('flow', { id: 'outlet-b', node: outletNode });
273
+ });
274
+
275
+ expect(container).toBeEmptyDOMElement();
276
+ expect(outletNode.innerHTML).toBe('<span>flow</span>');
277
+ });
278
+
279
+ it('adopts designer semantics when the handshake lands after mount', () => {
280
+ // The canvas bootstraps live-first: the flag rides a postMessage and can
281
+ // arrive after the page hydrated. The bridge re-checks it on pieces-store
282
+ // notifications, and every claim change notifies the store — so the flag
283
+ // is always seen before designer/live behavior could diverge.
284
+ delete window.__themeEditorDesigner;
285
+
286
+ const { container } = render(
287
+ <CheckoutPiecesProvider>
288
+ <CheckoutPiece piece="flow">
289
+ <span>flow</span>
290
+ </CheckoutPiece>
291
+ </CheckoutPiecesProvider>
292
+ );
293
+
294
+ expect(container.innerHTML).toBe('<span>flow</span>');
295
+
296
+ const outletNode = createOutletNode();
297
+ act(() => {
298
+ // SET_THEME_EDITOR_COOKIE landed, then the first canvas edit mounts an
299
+ // outlet — the registration notifies the store, the bridge re-checks.
300
+ window.__themeEditorDesigner = true;
301
+ registerCheckoutOutlet('flow', { id: 'outlet-a', node: outletNode });
302
+ });
303
+
304
+ expect(container).toBeEmptyDOMElement();
305
+ expect(outletNode.innerHTML).toBe('<span>flow</span>');
306
+ });
307
+ });
308
+
309
+ describe('checkout pieces registry', () => {
310
+ it('resolves duplicate outlets first-wins', () => {
311
+ const first = createOutletNode();
312
+ const second = createOutletNode();
313
+
314
+ registerCheckoutOutlet('summary', { id: 'first', node: first });
315
+ registerCheckoutOutlet('summary', { id: 'second', node: second });
316
+
317
+ expect(getCheckoutOutletClaim('summary')?.id).toBe('first');
318
+ expect(isCheckoutOutletWinner('summary', 'first')).toBe(true);
319
+ expect(isCheckoutOutletWinner('summary', 'second')).toBe(false);
320
+
321
+ // A duplicate id (an effect re-run) must not queue a second claim.
322
+ registerCheckoutOutlet('summary', { id: 'first', node: second });
323
+ expect(getCheckoutOutletClaim('summary')?.node).toBe(first);
324
+
325
+ // The next-in-line outlet inherits the claim when the winner leaves.
326
+ unregisterCheckoutOutlet('summary', 'first');
327
+ expect(getCheckoutOutletClaim('summary')?.id).toBe('second');
328
+
329
+ unregisterCheckoutOutlet('summary', 'second');
330
+ expect(getCheckoutOutletClaim('summary')).toBeNull();
331
+ });
332
+
333
+ it('reads the flow variant preference from the first-wins flow claim only', () => {
334
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
335
+
336
+ const first = createOutletNode();
337
+ const second = createOutletNode();
338
+
339
+ registerCheckoutOutlet('flow', { id: 'first', node: first });
340
+ // A claim without a preference means inherit.
341
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
342
+
343
+ registerCheckoutOutlet('flow', {
344
+ id: 'second',
345
+ node: second,
346
+ flowVariant: 'one-page'
347
+ });
348
+ // The duplicate outlet's preference never steers — first-wins.
349
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
350
+
351
+ unregisterCheckoutOutlet('flow', 'first');
352
+ expect(getCheckoutFlowVariantOverride()).toBe('one-page');
353
+ });
354
+
355
+ it('updates a registration in place: no reorder, notify only on change', () => {
356
+ const first = createOutletNode();
357
+ const second = createOutletNode();
358
+
359
+ registerCheckoutOutlet('flow', {
360
+ id: 'first',
361
+ node: first,
362
+ flowVariant: 'one-page'
363
+ });
364
+ registerCheckoutOutlet('flow', { id: 'second', node: second });
365
+
366
+ const listener = jest.fn();
367
+ const unsubscribe = subscribeCheckoutPieces(listener);
368
+
369
+ updateCheckoutOutletFlowVariant('flow', 'first', 'step-by-step');
370
+ expect(listener).toHaveBeenCalledTimes(1);
371
+ expect(getCheckoutFlowVariantOverride()).toBe('step-by-step');
372
+ // Still first-wins: the update never re-queues the registration.
373
+ expect(getCheckoutOutletClaim('flow')?.id).toBe('first');
374
+ expect(getCheckoutOutletClaim('flow')?.node).toBe(first);
375
+
376
+ // Same value again: nothing changed, nobody re-renders.
377
+ updateCheckoutOutletFlowVariant('flow', 'first', 'step-by-step');
378
+ expect(listener).toHaveBeenCalledTimes(1);
379
+
380
+ // Clearing back to inherit.
381
+ updateCheckoutOutletFlowVariant('flow', 'first', undefined);
382
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
383
+
384
+ // Unknown ids and pieces are a no-op, never a crash.
385
+ updateCheckoutOutletFlowVariant('flow', 'ghost', 'one-page');
386
+ updateCheckoutOutletFlowVariant('steps', 'first', 'one-page');
387
+ expect(getCheckoutFlowVariantOverride()).toBeNull();
388
+
389
+ unsubscribe();
390
+ });
391
+
392
+ it('detects the designer only through the editor-proven flag', () => {
393
+ delete window.__themeEditorDesigner;
394
+ expect(isThemeEditorDesigner()).toBe(false);
395
+
396
+ window.__themeEditorDesigner = true;
397
+ expect(isThemeEditorDesigner()).toBe(true);
398
+
399
+ delete window.__themeEditorDesigner;
400
+ });
401
+
402
+ it('refcounts piece presence across announce and retract', () => {
403
+ expect(isCheckoutPieceMounted('steps')).toBe(false);
404
+
405
+ announceCheckoutPiece('steps');
406
+ announceCheckoutPiece('steps');
407
+ expect(isCheckoutPieceMounted('steps')).toBe(true);
408
+
409
+ retractCheckoutPiece('steps');
410
+ expect(isCheckoutPieceMounted('steps')).toBe(true);
411
+
412
+ retractCheckoutPiece('steps');
413
+ expect(isCheckoutPieceMounted('steps')).toBe(false);
414
+
415
+ // A stray retract must not push the count negative and mask a later mount.
416
+ retractCheckoutPiece('steps');
417
+ announceCheckoutPiece('steps');
418
+ expect(isCheckoutPieceMounted('steps')).toBe(true);
419
+ });
420
+ });
@@ -0,0 +1,181 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ /**
5
+ * The checkout wiring carries invariants that live in the source text rather
6
+ * than in exported code:
7
+ *
8
+ * - the `checkout-page-body` slot must mount BEFORE the flow's subtree, so
9
+ * outlet targets register in their `useLayoutEffect` before the flow's
10
+ * mount latch runs (the teleport decision is made pre-first-paint);
11
+ * - no checkout slot may take `fallback={children}`, or a published
12
+ * composition could REPLACE the payment flow instead of arranging it;
13
+ * - the body's takeover affordance sits on the flow itself, and exactly one
14
+ * thing offers it — the slot's dashed band stands down for the region.
15
+ *
16
+ * They are checked against BOTH checkout layouts in the repo: the reference
17
+ * app and the CLI scaffold every new brand is generated from. The two must
18
+ * agree, because the route tables that decide which placeholder `/orders/
19
+ * checkout` composes (`route-slug.ts` here, `route-placeholder-scope.ts` in
20
+ * the editor) are global: the editor offers the four checkout templates on
21
+ * that route for EVERY brand. A scaffold that mounts no `checkout-page-body`
22
+ * slot would let a designer compose, publish, and watch the storefront render
23
+ * nothing — with the flow still claiming to be code-owned underneath.
24
+ *
25
+ * Like `account-slot-coverage`, this suite fs-reads the sources and skips
26
+ * whichever tree is not checked out next to the package.
27
+ */
28
+
29
+ const REPO_ROOT = path.resolve(__dirname, '../../../..');
30
+
31
+ const CHECKOUT_DIRS = {
32
+ 'the reference app': path.join(
33
+ REPO_ROOT,
34
+ 'apps/projectzeronext/src/app/[pz]/orders/checkout'
35
+ ),
36
+ 'the CLI scaffold': path.join(
37
+ REPO_ROOT,
38
+ 'packages/projectzero/app-template/src/app/[pz]/orders/checkout'
39
+ )
40
+ };
41
+
42
+ const countMatches = (source: string, pattern: RegExp) =>
43
+ (source.match(pattern) ?? []).length;
44
+
45
+ describe.each(Object.entries(CHECKOUT_DIRS))(
46
+ 'checkout slot coverage in %s',
47
+ (_name, checkoutDir) => {
48
+ const maybeIt = fs.existsSync(checkoutDir) ? it : it.skip;
49
+
50
+ const layoutPath = path.join(checkoutDir, 'layout.tsx');
51
+ const pagePath = path.join(checkoutDir, 'page.tsx');
52
+ const readLayout = () => fs.readFileSync(layoutPath, 'utf8');
53
+
54
+ maybeIt(
55
+ 'mounts each of the four checkout zones exactly once in the layout',
56
+ () => {
57
+ const layout = readLayout();
58
+
59
+ // Counted per SLOT, not per slug mention: the body slug is named twice on
60
+ // purpose — once by its slot, once by the region that offers its takeover
61
+ // — while "one slot per zone" is the invariant that actually matters.
62
+ expect(countMatches(layout, /<ChromeSlot\b/g)).toBe(3);
63
+
64
+ (['checkoutTop', 'checkoutBody', 'checkoutBottom'] as const).forEach(
65
+ (key) => {
66
+ expect(
67
+ countMatches(
68
+ layout,
69
+ // `[^>]` spans newlines, so a prettier reflow of the props cannot
70
+ // break this.
71
+ new RegExp(
72
+ `<ChromeSlot[^>]*PLACEHOLDER_SLUGS\\.${key}\\b[^>]*>`,
73
+ 'g'
74
+ )
75
+ )
76
+ ).toBe(1);
77
+ });
78
+ }
79
+ );
80
+
81
+ maybeIt('wraps the checkout subtree in the pieces provider', () => {
82
+ expect(readLayout()).toContain('CheckoutPiecesProvider');
83
+ });
84
+
85
+ maybeIt(
86
+ 'mounts the body slot before the flow (pre-paint latch ordering)',
87
+ () => {
88
+ const layout = readLayout();
89
+ const bodyMountIndex = layout.indexOf('PLACEHOLDER_SLUGS.checkoutBody');
90
+ const flowIndex = layout.indexOf('<ComposableRegion');
91
+
92
+ expect(bodyMountIndex).toBeGreaterThan(-1);
93
+ expect(flowIndex).toBeGreaterThan(-1);
94
+ // Ordering is load-bearing: outlets must register before the flow's
95
+ // mount latch decides where the flow renders.
96
+ expect(bodyMountIndex).toBeLessThan(flowIndex);
97
+ }
98
+ );
99
+
100
+ maybeIt('never hands children to a slot as fallback', () => {
101
+ // A slot with `fallback={children}` would let a composition replace the
102
+ // payment flow instead of positioning it.
103
+ expect(readLayout()).not.toContain('fallback={children}');
104
+ });
105
+
106
+ maybeIt('wires the flow piece in the checkout page', () => {
107
+ // The flow-never-lost guarantee is enforced by the runtime piece wrapper —
108
+ // this pins that the page actually mounts it. Without it every outlet a
109
+ // composition places is a dead target: the editor happily accepts the
110
+ // composition and the flow never moves.
111
+ expect(fs.readFileSync(pagePath, 'utf8')).toContain('piece="flow"');
112
+ });
113
+
114
+ maybeIt(
115
+ 'gives the checkout body a takeover affordance instead of an empty-zone band',
116
+ () => {
117
+ // The point of the region: the designer hovers the REAL checkout content
118
+ // and gets the same "Edit in theme editor" affordance every other page
119
+ // has, rather than a dashed band floating above content that answers a
120
+ // hover with nothing.
121
+ const layout = readLayout();
122
+
123
+ const regionOpen = layout.indexOf('<ComposableRegion');
124
+ const regionClose = layout.indexOf('</ComposableRegion>');
125
+ const childrenIndex = layout.indexOf('{children}');
126
+
127
+ expect(regionOpen).toBeGreaterThan(-1);
128
+ expect(regionClose).toBeGreaterThan(-1);
129
+ // The region takes the live flow, not a copy of it.
130
+ expect(childrenIndex).toBeGreaterThan(regionOpen);
131
+ expect(childrenIndex).toBeLessThan(regionClose);
132
+ expect(layout).toMatch(
133
+ /<ComposableRegion[^>]*slug=\{PLACEHOLDER_SLUGS\.checkoutBody\}/
134
+ );
135
+
136
+ // One slug, one CTA: the slot's own band steps aside for it.
137
+ expect(layout).toMatch(
138
+ /<ChromeSlot[^>]*PLACEHOLDER_SLUGS\.checkoutBody\b[^>]*emptyZoneBand=\{false\}[^>]*>/
139
+ );
140
+ }
141
+ );
142
+
143
+ maybeIt(
144
+ 'keeps the dashed band on the two zones that have no live content',
145
+ () => {
146
+ // Nothing else renders in those zones, so removing their band would make
147
+ // them undiscoverable again — the exact problem the band exists for.
148
+ const layout = readLayout();
149
+
150
+ (['checkoutTop', 'checkoutBottom'] as const).forEach(
151
+ (key) => {
152
+ const slot = layout.match(
153
+ new RegExp(`<ChromeSlot[^>]*PLACEHOLDER_SLUGS\\.${key}\\b[^>]*>`)
154
+ );
155
+
156
+ expect(slot).not.toBeNull();
157
+ expect(slot![0]).not.toContain('emptyZoneBand');
158
+ }
159
+ );
160
+ }
161
+ );
162
+
163
+ maybeIt(
164
+ 'no longer tells the designer the checkout body cannot be composed',
165
+ () => {
166
+ // The code-owned explainer said the flow "cannot be composed — use the
167
+ // zones above and below it". The body is composable now, so that sentence
168
+ // must not survive anywhere in this layout.
169
+ expect(readLayout()).not.toContain('CodeOwnedRegion');
170
+ }
171
+ );
172
+
173
+ maybeIt('explains the area it makes inert once it is composed', () => {
174
+ // The canvas click guard is keyed on being in the designer, not on the
175
+ // area being uncomposed. A composition that places no flow outlet leaves
176
+ // the real checkout visible and unclickable, so the region has to keep
177
+ // saying why.
178
+ expect(readLayout()).toMatch(/<ComposableRegion[^>]*composedNote=/);
179
+ });
180
+ }
181
+ );