@lilaquadrat/design-core 0.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 (86) hide show
  1. package/README.md +236 -0
  2. package/html/index.html +25 -0
  3. package/html/index.mail.html +15 -0
  4. package/html/index.server.html +29 -0
  5. package/package.json +148 -0
  6. package/src/client-entry.ts +28 -0
  7. package/src/components/partials/action.partial.vue +30 -0
  8. package/src/components/partials/client-only.partial.vue +11 -0
  9. package/src/components/partials/error.partial.vue +73 -0
  10. package/src/components/partials/main-components.partial.vue +167 -0
  11. package/src/components/partials/mediadetection.partial.vue +71 -0
  12. package/src/components/partials/qrcode.partial.vue +35 -0
  13. package/src/customs.ts +12 -0
  14. package/src/env.d.ts +1 -0
  15. package/src/functions/PaymenyProviderFactory.ts +27 -0
  16. package/src/functions/lila-dom.ts +100 -0
  17. package/src/functions/shopify.provider.ts +378 -0
  18. package/src/functions/stripe.provider.ts +181 -0
  19. package/src/globals.d.ts +19 -0
  20. package/src/index.ts +47 -0
  21. package/src/interfaces/AppState.interface.ts +4 -0
  22. package/src/interfaces/EditorConfiguration.interface.ts +15 -0
  23. package/src/interfaces/EventDeclaration.interface.ts +19 -0
  24. package/src/interfaces/FrontendConfig.interface.ts +42 -0
  25. package/src/interfaces/GenericEvents.interface.ts +6 -0
  26. package/src/interfaces/GenericState.interface.ts +5 -0
  27. package/src/interfaces/IconsPartial.ts +9 -0
  28. package/src/interfaces/IdTokenExtended.interface.ts +7 -0
  29. package/src/interfaces/ModuleBaseProps.interface.ts +8 -0
  30. package/src/interfaces/PaymentProvider.interface.ts +13 -0
  31. package/src/libs/ActionNotice.ts +235 -0
  32. package/src/libs/Models.class.ts +482 -0
  33. package/src/main.ts +84 -0
  34. package/src/mixins/createCookieString.ts +78 -0
  35. package/src/mixins/createModelDeclaration.ts +29 -0
  36. package/src/mixins/createRouter.ts +30 -0
  37. package/src/mixins/date.ts +23 -0
  38. package/src/mixins/formatSize.ts +12 -0
  39. package/src/mixins/getAnchor.ts +14 -0
  40. package/src/mixins/getRoutes.ts +50 -0
  41. package/src/mixins/hasSlotContent.ts +32 -0
  42. package/src/mixins/hooks.ts +104 -0
  43. package/src/mixins/loadComponents.ts +107 -0
  44. package/src/mixins/logger.ts +32 -0
  45. package/src/mixins/replaceVariables.ts +19 -0
  46. package/src/mixins/scroll.ts +83 -0
  47. package/src/models/Address.model.ts +24 -0
  48. package/src/models/Contact.model.ts +22 -0
  49. package/src/models.ts +2 -0
  50. package/src/plugins/auth.ts +121 -0
  51. package/src/plugins/currency.ts +26 -0
  52. package/src/plugins/events.ts +156 -0
  53. package/src/plugins/filters.ts +43 -0
  54. package/src/plugins/inview.ts +351 -0
  55. package/src/plugins/replacer.ts +18 -0
  56. package/src/plugins/resize.ts +128 -0
  57. package/src/plugins/signupFlow.ts +89 -0
  58. package/src/plugins/traceable.ts +53 -0
  59. package/src/plugins/translations.ts +29 -0
  60. package/src/plugins/youtube.ts +80 -0
  61. package/src/routes.ts +132 -0
  62. package/src/server-entry.ts +113 -0
  63. package/src/stores/calls.store.ts +33 -0
  64. package/src/stores/cart.store.ts +186 -0
  65. package/src/stores/content.store.ts +83 -0
  66. package/src/stores/editor.store.ts +27 -0
  67. package/src/stores/files.store.ts +166 -0
  68. package/src/stores/main.store.ts +184 -0
  69. package/src/stores/user.store.ts +124 -0
  70. package/src/translations/de.ts +138 -0
  71. package/src/views/content.view.vue +219 -0
  72. package/src/views/download.view.vue +143 -0
  73. package/src/views/editor.view.vue +243 -0
  74. package/src/views/login.view.vue +82 -0
  75. package/src/views/signup-account.view.vue +64 -0
  76. package/tooling/cypress/config.ts +80 -0
  77. package/tooling/cypress/generate-manifest.js +5 -0
  78. package/tooling/cypress/manifest-utils.mjs +40 -0
  79. package/tooling/cypress/run-parallel.mjs +77 -0
  80. package/tooling/cypress/support/commands.ts +436 -0
  81. package/tooling/cypress/support/e2e.ts +17 -0
  82. package/tooling/eslint.config.js +223 -0
  83. package/tooling/preview.plugin.ts +134 -0
  84. package/tooling/ssr-test/index.mjs +391 -0
  85. package/tooling/stylelint.config.mjs +24 -0
  86. package/tooling/vite.ts +246 -0
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ import useEditorStore from '../stores/editor.store';
3
+ import { hardCopy, prepareContent } from '@lilaquadrat/studio/lib/esm/frontend';
4
+ import { getCurrentInstance, nextTick, onMounted, ref } from 'vue';
5
+ import { type Content, type ContentWithPositions, type EditorActiveModule, type StudioIframeMessage } from '@lilaquadrat/interfaces';
6
+ import { onBeforeUnmount } from 'vue';
7
+ import { watch } from 'vue';
8
+ import type { AppEditorConfiguration } from '@lilaquadrat/interfaces';
9
+ import { loadViaDeclarationSync } from '../mixins/loadComponents';
10
+ import useContentStore from '../stores/content.store';
11
+ import useMainStore from '../stores/main.store';
12
+ import { useEvents } from '../plugins/events';
13
+ import { scrollToSelector } from '../mixins/scroll';
14
+
15
+ const { eventDeclarations} = useEvents();
16
+ const currentInstance = getCurrentInstance();
17
+ const editorStore = useEditorStore();
18
+ const contentStore = useContentStore();
19
+ const content = ref<ContentWithPositions>({ settings: {}, top: [], content: [], bottom: [], additional: {} });
20
+ const siteSettings = ref<Omit<Content, 'modules'|'genericData'|'childData'>>();
21
+ const live = ref<boolean>(false);
22
+ // const parentUrl = ref<string>(`${window.location.protocol}//${window.location.host}/editor`);
23
+ const contentCache = ref<Content['modules']>();
24
+ const settingsCache = ref<AppEditorConfiguration>();
25
+ const active = ref<EditorActiveModule>();
26
+ const mainStore = useMainStore();
27
+ const init = ref<boolean>(false);
28
+
29
+ watch(siteSettings, () => {
30
+
31
+ /**
32
+ * if the target type changes we need to update the available modules
33
+ */
34
+ const useTarget = siteSettings.value?.target || 'browser';
35
+
36
+ if(useTarget !== mainStore.target) {
37
+
38
+ const target = siteSettings.value?.target === 'browser' || !siteSettings.value?.target
39
+ ? 'browser'
40
+ : siteSettings.value?.target
41
+
42
+ mainStore.target = useTarget;
43
+
44
+ postModules();
45
+ postCustomModules();
46
+
47
+ if (currentInstance) {
48
+
49
+ loadViaDeclarationSync(target === 'browser' ? editorStore.modulesBrowser : editorStore.modulesMail, 'lila', 'module', currentInstance.appContext.app);
50
+ loadViaDeclarationSync(target === 'browser' ? editorStore.partialsBrowser : editorStore.partialsMail, 'lila', 'partial', currentInstance.appContext.app);
51
+
52
+ }
53
+
54
+ }
55
+
56
+ })
57
+
58
+ function messageHandler (message: StudioIframeMessage) {
59
+
60
+ if (message.data.type === 'studio-content') {
61
+ console.groupCollapsed('MESSAGE_STUDIO_CONTENT');
62
+ console.debug(message.data.data);
63
+ console.groupEnd();
64
+
65
+ contentCache.value = message.data.data;
66
+ updateContent();
67
+ }
68
+
69
+ //editor main configuration
70
+ if (message.data.type === 'studio-settings') {
71
+ console.groupCollapsed('MESSAGE_STUDIO_SETTINGS');
72
+ console.debug(message.data.data);
73
+ console.groupEnd();
74
+
75
+ settingsCache.value = message.data.data;
76
+ mainStore.configuration = message.data.data;
77
+ updateContent();
78
+ }
79
+
80
+ if (message.data.type === 'studio-editor-settings') {
81
+ console.groupCollapsed('MESSAGE_STUDIO_EDITOR_SETTINGS');
82
+ console.debug(message.data.data);
83
+ console.groupEnd();
84
+
85
+ siteSettings.value = message.data.data;
86
+
87
+ if(!init.value) {
88
+
89
+ postModules();
90
+ postCustomModules();
91
+ postEvents();
92
+ init.value = true;
93
+
94
+ }
95
+
96
+ updateContent();
97
+ updateContext();
98
+
99
+ }
100
+
101
+ if (message.data.type === 'studio-active') {
102
+ console.groupCollapsed('MESSAGE_STUDIO_ACTIVE');
103
+ console.debug(message.data.data);
104
+ console.groupEnd();
105
+
106
+ active.value = message.data.data;
107
+
108
+ if (active.value) {
109
+
110
+ scrollToModule(active.value);
111
+
112
+ }
113
+ }
114
+
115
+ if (message.data.type === 'studio-cookie-reset') {
116
+ console.groupCollapsed('MESSAGE_STUDIO_COOKIE_RESET');
117
+ console.debug(message.data.data);
118
+ console.groupEnd();
119
+
120
+ resetCookies();
121
+ }
122
+ }
123
+
124
+ onMounted(() => {
125
+
126
+ if (window.origin === 'null') {
127
+ live.value = true;
128
+ }
129
+
130
+ if (live.value) return;
131
+
132
+ removeListeners()
133
+ addListeners();
134
+
135
+ window.parent.postMessage('studio-design-settings', '*');
136
+ window.parent.postMessage('studio-design-ready', '*');
137
+ });
138
+
139
+ onBeforeUnmount(() => removeListeners());
140
+
141
+ function removeListeners () {
142
+ window.removeEventListener('message', messageHandler, false);
143
+ }
144
+
145
+ function addListeners () {
146
+ window.addEventListener('message', messageHandler, false);
147
+ }
148
+
149
+ function updateContent () {
150
+ content.value = prepareContent({ modules: contentCache.value, ...siteSettings.value });
151
+ }
152
+
153
+ function postModules () {
154
+
155
+ console.log('POST MODULES', mainStore.target);
156
+
157
+ const modules = mainStore.target === 'browser'
158
+ ? editorStore.availableModulesWithRevision
159
+ : editorStore.availableModulesWithRevisionMail
160
+
161
+ window.parent.postMessage(
162
+ {
163
+ type: 'studio-design-modules-with-revision',
164
+ data: hardCopy(modules),
165
+ },
166
+ '*',
167
+ );
168
+
169
+ }
170
+
171
+ function postCustomModules () {
172
+
173
+ const modules = mainStore.target === 'browser'
174
+ ? mainStore.customModulesBrowser
175
+ : mainStore.customModulesMail
176
+
177
+ if (!modules) return;
178
+
179
+ window.parent.postMessage(
180
+ {
181
+ type: 'studio-design-custom-modules',
182
+ data: hardCopy(modules),
183
+ },
184
+ '*',
185
+ );
186
+
187
+ }
188
+
189
+ function postEvents () {
190
+
191
+ window.parent.postMessage(
192
+ {
193
+ type: 'studio-design-events',
194
+ data: eventDeclarations,
195
+ },
196
+ '*',
197
+ );
198
+
199
+ }
200
+
201
+ /**
202
+ * sets sitetitle and description
203
+ */
204
+ function updateContext () {
205
+
206
+ contentStore.setContext({
207
+ sitetitle : siteSettings?.value?.settings?.title,
208
+ description: siteSettings?.value?.settings?.description,
209
+ })
210
+
211
+ }
212
+
213
+ function resetCookies () {
214
+ const cookies = document.cookie.split(';').filter((single) => single);
215
+
216
+ cookies.forEach((single) => {
217
+ const name = single.split('=');
218
+
219
+ document.cookie = `${name[0].trim()}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=None;`;
220
+ });
221
+ }
222
+
223
+ function scrollToModule (active: EditorActiveModule): void {
224
+
225
+ nextTick().then(() => {
226
+ const baseModule = contentCache.value?.find((single) => single.uuid === active.uuid);
227
+ const containerSelector = baseModule?.position === 'content' || !baseModule?.position ? '.lila-content-module .container:not(.top, .bottom)' : `.lila-content-module .container.${baseModule.position}`;
228
+ const contentPosition: Content['modules'] = baseModule?.position === 'content' || !baseModule?.position ? content.value.content : content.value[baseModule.position];
229
+ const index = contentPosition?.findIndex((single) => single.uuid === active.uuid);
230
+
231
+ if (!index && index !== 0) return;
232
+
233
+ scrollToSelector(`${containerSelector} .lila-module:nth-child(${index + 1}), ${containerSelector} .partial-container:nth-child(${index + 1})`,)
234
+
235
+ });
236
+
237
+ }
238
+ </script>
239
+ <template>
240
+ <article class="editor-screen screen">
241
+ <lila-content-module :content="content" />
242
+ </article>
243
+ </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import type AppState from '../interfaces/AppState.interface';
3
+ import { auth } from '../plugins/auth';
4
+ import useMainStore from '../stores/main.store';
5
+ import useUserStore from '../stores/user.store';
6
+ import type { RedirectLoginResult } from '@auth0/auth0-spa-js';
7
+ import StudioSDK from '@lilaquadrat/sdk';
8
+ import { onBeforeMount, ref } from 'vue';
9
+ import { useRouter } from 'vue-router';
10
+ import { routeForLock } from '../plugins/signupFlow';
11
+
12
+ const mainStore = useMainStore();
13
+ const userStore = useUserStore();
14
+ const router = useRouter();
15
+ const error = ref<boolean>(false);
16
+ const hint = ref<string>();
17
+
18
+ onBeforeMount(async () => {
19
+
20
+ let loginResult: RedirectLoginResult<AppState>;
21
+
22
+ // Attempt to handle the authentication callback.
23
+ try {
24
+
25
+ loginResult = await auth.handleCallback();
26
+
27
+ } catch (e: any) {
28
+
29
+ console.error(e);
30
+ error.value = true;
31
+ return;
32
+
33
+ }
34
+
35
+ await userStore.updateLock();
36
+
37
+ console.log(userStore.locked, loginResult);
38
+
39
+ const sdk = new StudioSDK(mainStore.apiConfig);
40
+
41
+ if(userStore.locked === 'user-connect' && loginResult?.appState?.customerId) {
42
+
43
+ try {
44
+
45
+ // Attempt to connect the user with the provided customerId.
46
+ await sdk.members.me.connect(loginResult?.appState?.customerId);
47
+ await userStore.updateLock();
48
+
49
+ } catch (e) {
50
+
51
+ console.error(e);
52
+ console.error('CONNECT_ACCOUNT_TO_CUSTOMER_FAILED');
53
+
54
+ }
55
+
56
+ }
57
+
58
+ if(!userStore.userData?.emailConfirmed && loginResult?.appState?.emailConfirmationCode) {
59
+
60
+ try {
61
+
62
+ // Attempt to confirm the email address with the provided code
63
+ await sdk.members.me.confirmEmail(loginResult?.appState?.emailConfirmationCode)
64
+ await userStore.updateLock();
65
+
66
+ } catch (e) {
67
+
68
+ console.error(e);
69
+ console.error('EMAIL_CONFIRMATION_FAILED');
70
+
71
+ }
72
+
73
+ }
74
+
75
+ router.replace(routeForLock(userStore.locked));
76
+
77
+ })
78
+
79
+ </script>
80
+ <template>
81
+ <lila-error-partial v-if="error" :status="400" :hint="hint" type="members" />
82
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import { auth } from '../plugins/auth';
3
+ import useMainStore from '../stores/main.store';
4
+ import useUserStore from '../stores/user.store';
5
+ import { prepareContent } from '@lilaquadrat/studio/lib/esm/frontend';
6
+ import type { ContentWithPositions } from '@lilaquadrat/interfaces';
7
+ import { computed, onBeforeMount } from 'vue';
8
+ import { useRoute, useRouter } from 'vue-router';
9
+
10
+ const route = useRoute();
11
+ const router = useRouter();
12
+ const userStore = useUserStore();
13
+ const store = useMainStore();
14
+
15
+ onBeforeMount(() => {
16
+
17
+ if(route.query.customerId) {
18
+
19
+ userStore.setCustomer({ _id: route.query.customerId as string });
20
+
21
+ }
22
+
23
+ if(route.query.emailConfirmationCode) {
24
+
25
+ userStore.emailConfirmationCode = route.query.emailConfirmationCode as string;
26
+
27
+ }
28
+
29
+ if(!auth.isAuth.value) {
30
+
31
+ auth.triggerSignup(userStore.customer?._id, userStore.emailConfirmationCode);
32
+
33
+ } else {
34
+
35
+ router.push({ name: 'members' });
36
+
37
+ }
38
+
39
+ });
40
+
41
+ const contentMerged = computed(() => {
42
+
43
+ const data = store.staticData?.redirect;
44
+
45
+ if(data) return prepareContent(data) as unknown as ContentWithPositions;
46
+ return prepareContent({}) as unknown as ContentWithPositions;
47
+
48
+ });
49
+
50
+ </script>
51
+ <template>
52
+ <section class="signup-account-view">
53
+ <lila-content-module v-if="contentMerged" :content="contentMerged" />
54
+ </section>
55
+ </template>
56
+ <style lang="less" scoped>
57
+ .signup-account-view {
58
+ display: grid;
59
+ align-content: center;
60
+ justify-content: center;
61
+
62
+ height: 80vh;
63
+ }
64
+ </style>
@@ -0,0 +1,80 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { defineConfig } from 'cypress'
3
+ import { rebuildManifest, writeManifest, screenshotToManifestPath } from './manifest-utils.mjs'
4
+
5
+ type CypressConfig = Parameters<typeof defineConfig>[0];
6
+
7
+ /**
8
+ * the screenshot harness: seeds cypress/manifest.js from the existing
9
+ * screenshots, appends every new one as it is taken and pins the browser window
10
+ * so a run on another machine produces comparable images.
11
+ *
12
+ * pass overrides for anything project specific (baseUrl, viewport, extra
13
+ * setupNodeEvents).
14
+ */
15
+ export function createCypressConfig (overrides: CypressConfig = {}) {
16
+
17
+ const { e2e: e2eOverrides, ...rest } = overrides;
18
+
19
+ return defineConfig({
20
+ e2e: {
21
+ baseUrl : 'http://localhost:5173/test',
22
+ /**
23
+ * the shared commands live in the package, so a project does not need a
24
+ * cypress/support folder of its own
25
+ */
26
+ supportFile : fileURLToPath(new URL('./support/e2e.ts', import.meta.url)),
27
+ viewportHeight: 1920,
28
+ viewportWidth : 1080,
29
+ video : false,
30
+ testIsolation : false,
31
+ ...e2eOverrides,
32
+ /**
33
+ * always the framework handler: it chains into an override at the end, so
34
+ * a project can add its own tasks without losing the manifest wiring
35
+ */
36
+ setupNodeEvents (on, config) {
37
+ const root = process.cwd();
38
+ const files = rebuildManifest(root);
39
+
40
+ console.log(`[manifest] seeded with ${files.length} existing screenshots`);
41
+
42
+ on('after:screenshot', (details) => {
43
+ files.push(screenshotToManifestPath(root, details.path));
44
+ writeManifest(root, files);
45
+ });
46
+
47
+ on('before:browser:launch', (browser, launchOptions) => {
48
+ if (browser.name === 'electron') {
49
+ launchOptions.preferences.width = 1920
50
+ launchOptions.preferences.height = 3000
51
+ }
52
+
53
+ if (browser.family === 'chromium' && browser.name !== 'electron') {
54
+ launchOptions.args.push('--window-size=1920,1080');
55
+ }
56
+
57
+ if (browser.name === 'firefox') {
58
+ launchOptions.args.push('--width=1920')
59
+ launchOptions.args.push('--height=3000')
60
+
61
+ launchOptions.preferences['layout.css.devPixelsPerPx'] = '1.0'
62
+ }
63
+
64
+ if (browser.name === 'chrome' && browser.isHeadless) {
65
+ launchOptions.args.push('--window-size=1920,3000');
66
+ launchOptions.args.push('--force-device-scale-factor=1');
67
+ }
68
+
69
+ return launchOptions
70
+ })
71
+
72
+ return e2eOverrides?.setupNodeEvents?.(on, config);
73
+ },
74
+ },
75
+ ...rest,
76
+ })
77
+
78
+ }
79
+
80
+ export default createCypressConfig;
@@ -0,0 +1,5 @@
1
+ import { rebuildManifest } from './manifest-utils.mjs';
2
+
3
+ const files = rebuildManifest(process.cwd());
4
+
5
+ console.log(`[manifest] wrote ${files.length} screenshots`);
@@ -0,0 +1,40 @@
1
+ import { writeFileSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { resolve, join, relative } from 'path';
3
+
4
+ export function walkImages (dir, base = dir) {
5
+ return readdirSync(dir).flatMap((entry) => {
6
+ const full = join(dir, entry);
7
+
8
+ if (statSync(full).isDirectory()) return walkImages(full, base);
9
+ if (/\.(png|jpe?g)$/i.test(entry)) return [full.slice(base.length + 1).replace(/\\/g, '/')];
10
+ return [];
11
+ });
12
+ }
13
+
14
+ export function writeManifest (root, files) {
15
+ const content = `window.__SCREENSHOTS__ = ${JSON.stringify(files.slice().sort(), null, 2)};\n`;
16
+
17
+ writeFileSync(resolve(root, 'cypress/manifest.js'), content, 'utf-8');
18
+ }
19
+
20
+ export function scanScreenshots (root) {
21
+ const screenshotsDir = resolve(root, 'cypress/screenshots');
22
+
23
+ if (!existsSync(screenshotsDir)) return [];
24
+
25
+ return walkImages(screenshotsDir).map((f) => `screenshots/${f}`);
26
+ }
27
+
28
+ export function rebuildManifest (root = process.cwd()) {
29
+ const files = scanScreenshots(root);
30
+
31
+ writeManifest(root, files);
32
+
33
+ return files;
34
+ }
35
+
36
+ export function screenshotToManifestPath (root, absolutePath) {
37
+ const screenshotsDir = resolve(root, 'cypress/screenshots');
38
+
39
+ return `screenshots/${relative(screenshotsDir, absolutePath).replace(/\\/g, '/')}`;
40
+ }
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'child_process';
2
+ import { readdirSync, existsSync, readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ // Measured single-run durations (seconds) for the base design specs. A project
6
+ // with its own spec set overrides or extends these through cypress/durations.json.
7
+ // Measured single-run durations (seconds) — used to bin-pack shards evenly instead
8
+ // of a blind round-robin, since a few specs (picture, picturegroup, timeline, emotion,
9
+ // gallery) run 3-6x longer than the rest and would otherwise dominate one shard.
10
+ // Unlisted specs (new ones added later) fall back to the average below.
11
+ const KNOWN_DURATIONS = {
12
+ 'blog-intro.cy.ts' : 41,
13
+ 'event.cy.ts' : 25,
14
+ 'gallery.cy.ts' : 194,
15
+ 'navigation.cy.ts' : 64,
16
+ 'prices.cy.ts' : 19,
17
+ 'contact.cy.ts' : 94,
18
+ 'facts.cy.ts' : 103,
19
+ 'location.cy.ts' : 3,
20
+ 'pictureandtext.cy.ts': 152,
21
+ 'text.cy.ts' : 29,
22
+ 'compare.cy.ts' : 39,
23
+ 'eventlist.cy.ts' : 44,
24
+ 'index.cy.ts' : 79,
25
+ 'picture.cy.ts' : 376,
26
+ 'quote.cy.ts' : 40,
27
+ 'FAQ.cy.ts' : 95,
28
+ 'emotion.cy.ts' : 197,
29
+ 'footer.cy.ts' : 26,
30
+ 'menu.cy.ts' : 31,
31
+ 'picturegroup.cy.ts' : 300,
32
+ 'timeline.cy.ts' : 256,
33
+ };
34
+ const root = process.cwd();
35
+ const durationsFile = resolve(root, 'cypress/durations.json');
36
+ const DURATIONS = existsSync(durationsFile)
37
+ ? { ...KNOWN_DURATIONS, ...JSON.parse(readFileSync(durationsFile, 'utf-8')) }
38
+ : KNOWN_DURATIONS;
39
+ const AVG_DURATION = Object.values(DURATIONS).reduce((a, b) => a + b, 0) / Object.keys(DURATIONS).length;
40
+ const cypressBin = resolve(root, 'node_modules/.bin/cypress');
41
+ const specsDir = resolve(root, 'cypress/e2e');
42
+ const specNames = readdirSync(specsDir).filter((f) => f.endsWith('.cy.ts'));
43
+ const shardCount = Math.max(1, Math.min(Number(process.env.CY_SHARDS) || 4, specNames.length));
44
+ const shards = Array.from({ length: shardCount }, () => ({ specs: [], total: 0 }));
45
+
46
+ // Longest-processing-time-first: sort heaviest specs first, always drop the next
47
+ // spec into whichever shard currently has the least total weight.
48
+ specNames
49
+ .map((name) => ({ name, duration: DURATIONS[name] ?? AVG_DURATION }))
50
+ .sort((a, b) => b.duration - a.duration)
51
+ .forEach(({ name, duration }) => {
52
+ const lightest = shards.reduce((min, s) => (s.total < min.total ? s : min), shards[0]);
53
+
54
+ lightest.specs.push(`cypress/e2e/${name}`);
55
+ lightest.total += duration;
56
+ });
57
+
58
+ console.log(`[parallel] running ${specNames.length} specs across ${shardCount} shards (est. longest shard ~${Math.round(Math.max(...shards.map((s) => s.total)) / 60)}m)`);
59
+
60
+ // Each shard writes its own partial cypress/manifest.js as it goes (from its own
61
+ // in-process view of the screenshots dir), so the manifest is only trustworthy once
62
+ // every shard has finished — the "cy:parallel" script always follows this up with
63
+ // a single authoritative `generate-manifest.js` rebuild over the full directory.
64
+ const runs = shards.map(({ specs }, i) => new Promise((resolveRun, rejectRun) => {
65
+ const child = spawn(cypressBin, ['run', '--spec', specs.join(',')], { stdio: 'inherit' });
66
+
67
+ child.on('exit', (code) => {
68
+ if (code === 0) resolveRun();
69
+ else rejectRun(new Error(`shard ${i} failed (exit code ${code})`));
70
+ });
71
+ }));
72
+ const results = await Promise.allSettled(runs);
73
+ const failed = results.filter((r) => r.status === 'rejected');
74
+
75
+ failed.forEach((r) => console.error(`[parallel] ${r.reason.message}`));
76
+
77
+ if (failed.length > 0) process.exit(1);