@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.
- package/CHANGELOG.md +14 -31
- package/package.json +4 -4
- package/src/__tests__/checkout-pieces.test.tsx +420 -0
- package/src/__tests__/checkout-slot-coverage.test.ts +181 -0
- package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
- package/src/__tests__/code-owned-region.test.tsx +30 -6
- package/src/__tests__/composable-region.test.tsx +481 -0
- package/src/__tests__/part-styles.test.ts +24 -4
- package/src/__tests__/registry-parity.test.ts +3 -0
- package/src/__tests__/route-slug.test.ts +11 -0
- package/src/blocks/__tests__/checkout-hosted-parts.test.ts +203 -0
- package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
- package/src/blocks/block-renderer-registry.tsx +6 -0
- package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
- package/src/blocks/checkout-outlet-base.tsx +241 -0
- package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
- package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
- package/src/chrome/checkout-piece.tsx +145 -0
- package/src/chrome/checkout-pieces-provider.tsx +28 -0
- package/src/chrome/checkout-pieces.ts +173 -0
- package/src/chrome/chrome-fallback.tsx +8 -26
- package/src/chrome/chrome-slot.tsx +25 -26
- package/src/chrome/code-owned-region.tsx +21 -9
- package/src/chrome/composable-region-client.tsx +228 -0
- package/src/chrome/composable-region.tsx +58 -0
- package/src/chrome/composition-source.ts +34 -0
- package/src/chrome/composition-state.ts +49 -0
- package/src/chrome/live-checkout-page-context.tsx +3 -2
- package/src/chrome/placeholder-slugs.ts +14 -4
- package/src/chrome/route-slug.ts +6 -3
- package/src/hooks/use-designer-features.tsx +41 -10
- package/src/index.ts +6 -0
- package/src/utils/__tests__/checkout-safety.test.ts +374 -0
- package/src/utils/checkout-safety.ts +177 -0
- package/src/utils/index.ts +47 -8
- package/src/utils/part-styles.ts +4 -0
- 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.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
);
|