cabloy 5.1.144 → 5.1.145

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 (28) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/playwright-e2e.yml +2 -2
  3. package/CHANGELOG.md +14 -0
  4. package/cabloy-docs/backend/markdown-guide.md +3 -2
  5. package/cabloy-docs/frontend/markdown-guide.md +1 -1
  6. package/e2e/specs/a-commerce.spec.ts +0 -496
  7. package/e2e/specs/markdown.spec.ts +575 -0
  8. package/package.json +3 -3
  9. package/vona/pnpm-lock.yaml +25 -0
  10. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/package.json +2 -1
  11. package/vona/src/suite-vendor/a-cabloy/modules/a-markdown/src/bean/bean.markdown.ts +47 -2
  12. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  13. package/zova/packages-utils/zova-jsx/package.json +2 -2
  14. package/zova/packages-zova/zova/package.json +3 -3
  15. package/zova/packages-zova/zova-core/package.json +1 -1
  16. package/zova/packages-zova/zova-core/src/core/component/locale.ts +2 -0
  17. package/zova/src/suite/a-home/modules/home-base/src/.metadata/index.ts +2 -17
  18. package/zova/src/suite/a-home/modules/home-base/src/service/routerGuards.ts +0 -6
  19. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/controller.tsx +1 -4
  20. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.locale.tsx +1 -1
  21. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/controller.tsx +1 -4
  22. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.locale.tsx +1 -1
  23. package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
  24. package/zova/src/suite-vendor/a-zova/modules/a-ssr/src/monkey.ts +1 -0
  25. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  26. package/zova/src/suite-vendor/a-zova/package.json +3 -3
  27. package/zova/src/suite/a-home/modules/home-base/src/service/locale.ts +0 -11
  28. /package/e2e/specs/{account.spec.ts → home-user-account.spec.ts} +0 -0
@@ -0,0 +1,575 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ import { expect, test } from '@playwright/test';
4
+
5
+ function collectPageErrors(page: Page) {
6
+ const errors: Error[] = [];
7
+ page.on('pageerror', error => {
8
+ errors.push(error);
9
+ });
10
+ return errors;
11
+ }
12
+
13
+ function collectConsoleErrors(page: Page) {
14
+ const errors: string[] = [];
15
+ page.on('console', message => {
16
+ if (message.type() === 'error' || /hydration mismatch/i.test(message.text())) {
17
+ errors.push(message.text());
18
+ }
19
+ });
20
+ return errors;
21
+ }
22
+
23
+ function waitForApiResponse(page: Page, method: string, path: string | RegExp) {
24
+ return page.waitForResponse(response => {
25
+ const url = new URL(response.url());
26
+ return (
27
+ response.request().method() === method &&
28
+ (typeof path === 'string' ? url.pathname === path : path.test(url.pathname)) &&
29
+ !response.request().headers()['x-vona-openapi-schema']
30
+ );
31
+ });
32
+ }
33
+
34
+ function expectTableIdentity(value: unknown) {
35
+ expect(['string', 'number']).toContain(typeof value);
36
+ }
37
+
38
+ async function login(
39
+ page: Page,
40
+ path: string,
41
+ username: string,
42
+ password: string,
43
+ hydratedSite: string,
44
+ ) {
45
+ await page.goto(path, { waitUntil: 'load' });
46
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', hydratedSite);
47
+ if (!page.url().includes('/login')) {
48
+ await page.waitForURL(/\/login(?:\?|$)/);
49
+ }
50
+ if (page.url().includes('/login')) {
51
+ const usernameInput = page.getByPlaceholder('Your Username');
52
+ const passwordInput = page.getByPlaceholder('Your Password');
53
+ await usernameInput.fill(username);
54
+ await passwordInput.fill(password);
55
+ await expect(usernameInput).toHaveValue(username);
56
+ await expect(passwordInput).toHaveValue(password);
57
+ await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue('');
58
+ const loginResponse = waitForApiResponse(page, 'POST', '/api/home/user/passport/login');
59
+ await page.getByRole('button', { name: 'Login', exact: true }).click();
60
+ const loginResponseValue = await loginResponse;
61
+ if (!loginResponseValue.ok()) {
62
+ throw new Error(
63
+ `login failed: ${loginResponseValue.status()} ${await loginResponseValue.text()}`,
64
+ );
65
+ }
66
+ await expect(page).not.toHaveURL(/\/login(?:\?|$)/);
67
+ }
68
+ }
69
+
70
+ test(
71
+ 'Commerce Product Content: SSR renders stored safe HTML and hydrates without errors',
72
+ { tag: ['@web', '@flow', '@product'] },
73
+ async ({ browser }, testInfo) => {
74
+ test.setTimeout(60_000);
75
+ const suffix = `${testInfo.workerIndex}-${testInfo.parallelIndex ?? testInfo.retry}-${Date.now()}`;
76
+ const categoryName = `E2E Product Content Web Category ${suffix}`;
77
+ const productTitle = `E2E Product Content Web ${suffix}`;
78
+ const skuCode = `E2E-PC-WEB-${suffix}`;
79
+ const markdown = `# Product details ${suffix}\n\nA **durable** product.\n\n==highlighted product ${suffix}==\n\n\`\`\`ts\nconst product = '${suffix}';\n\`\`\`\n\n- [ ] Open task\n- [x] Completed task`;
80
+ const unsafeHtml = '<a href="javascript:alert(\'unsafe\')">unsafe</a>';
81
+ const adminContext = await browser.newContext();
82
+ const adminPage = await adminContext.newPage();
83
+ const webContext = await browser.newContext();
84
+ const webPage = await webContext.newPage();
85
+ const webPageErrors = collectPageErrors(webPage);
86
+ const webConsoleErrors = collectConsoleErrors(webPage);
87
+ const categoryActionPath = '/api/commerce/catalog/category';
88
+ const productActionPath = '/api/commerce/catalog/product';
89
+ const skuActionPath = '/api/commerce/catalog/sku';
90
+ const stockAdjustPath = '/api/commerce/trade/stockBalance/adjustStock';
91
+ let categoryId: number | string | undefined;
92
+ let productId: number | string | undefined;
93
+ let skuId: number | string | undefined;
94
+ let headers: { Authorization: string } | undefined;
95
+ try {
96
+ await login(adminPage, '/commerce-admin/', 'admin', '123456', 'commerceAdmin');
97
+ const accessToken = (await adminContext.cookies()).find(
98
+ cookie => cookie.name === 'token',
99
+ )?.value;
100
+ expect(accessToken).toBeTruthy();
101
+ headers = { Authorization: `Bearer ${accessToken}` };
102
+
103
+ const categoryResponse = await adminPage.request.post(categoryActionPath, {
104
+ data: { name: categoryName, published: true },
105
+ headers,
106
+ });
107
+ expect(categoryResponse.ok()).toBeTruthy();
108
+ categoryId = (await categoryResponse.json()).data;
109
+ expectTableIdentity(categoryId);
110
+
111
+ const productResponse = await adminPage.request.post(productActionPath, {
112
+ data: {
113
+ categoryId,
114
+ title: productTitle,
115
+ published: true,
116
+ productContentForm: {
117
+ descriptionMarkdown: `${markdown}\n\n${unsafeHtml}`,
118
+ },
119
+ },
120
+ headers,
121
+ });
122
+ expect(productResponse.ok()).toBeTruthy();
123
+ productId = (await productResponse.json()).data;
124
+ expectTableIdentity(productId);
125
+
126
+ const skuResponse = await adminPage.request.post(skuActionPath, {
127
+ data: { code: skuCode, productId, priceCents: 1999, lifecycle: 'active' },
128
+ headers,
129
+ });
130
+ expect(skuResponse.ok()).toBeTruthy();
131
+ skuId = (await skuResponse.json()).data;
132
+ expectTableIdentity(skuId);
133
+
134
+ const stockResponse = await adminPage.request.post(stockAdjustPath, {
135
+ data: {
136
+ skuId,
137
+ delta: 1,
138
+ reason: 'Product content SSR E2E fixture',
139
+ correlationId: `product-content-web-${suffix}`,
140
+ },
141
+ headers,
142
+ });
143
+ expect(stockResponse.ok()).toBeTruthy();
144
+
145
+ const productPath = `/commerce/product/${productId}`;
146
+ const ssrResponse = await webPage.request.get(productPath);
147
+ expect(ssrResponse.ok()).toBeTruthy();
148
+ const ssrHtml = await ssrResponse.text();
149
+ expect(ssrHtml.toLowerCase()).not.toContain('data-zova-hydrated');
150
+ expect(ssrHtml).toContain(`<h1>Product details ${suffix}</h1>`);
151
+ expect(ssrHtml).toContain('<strong>durable</strong>');
152
+ expect(ssrHtml).toContain(`<mark>highlighted product ${suffix}</mark>`);
153
+ expect(ssrHtml).toMatch(/<code class="language-ts"><span class="hljs-/);
154
+ expect(ssrHtml).toMatch(
155
+ new RegExp(`<code class="language-ts">[\\s\\S]*${suffix}[\\s\\S]*</code>`),
156
+ );
157
+ expect(ssrHtml).toContain('<ul data-type="taskList">');
158
+ expect(ssrHtml).toContain('<input type="checkbox" disabled="disabled" />');
159
+ expect(ssrHtml).toContain('<input type="checkbox" checked="checked" disabled="disabled" />');
160
+ expect(ssrHtml).not.toMatch(/<a[^>]+javascript:/i);
161
+
162
+ const documentResponse = await webPage.goto(productPath, { waitUntil: 'load' });
163
+ expect(documentResponse?.ok()).toBeTruthy();
164
+ await expect(webPage.locator('html')).toHaveAttribute('data-zova-hydrated', 'commerce');
165
+ await expect(webPage.getByRole('heading', { name: productTitle })).toBeVisible();
166
+ await expect(webPage.locator('.product-description h1')).toHaveText(
167
+ `Product details ${suffix}`,
168
+ );
169
+ await expect(webPage.locator('.product-description strong')).toHaveText('durable');
170
+ await expect(webPage.locator('.product-description mark')).toHaveText(
171
+ `highlighted product ${suffix}`,
172
+ );
173
+ const description = webPage.locator('.product-description');
174
+ await expect(description).not.toHaveClass(/\bprose\b/);
175
+ const codeBlock = description.locator('pre > code.language-ts');
176
+ await expect(codeBlock).toHaveText(`const product = '${suffix}';`);
177
+ await expect(codeBlock.locator('span[class*="hljs-"]')).not.toHaveCount(0);
178
+ await expect(description.locator('ul[data-type="taskList"]')).toHaveCount(1);
179
+ await expect(description.locator('input[type="checkbox"]')).toHaveCount(2);
180
+ await expect(description.locator('input[type="checkbox"]').nth(0)).not.toBeChecked();
181
+ await expect(description.locator('input[type="checkbox"]').nth(0)).toBeDisabled();
182
+ await expect(description.locator('input[type="checkbox"]').nth(1)).toBeChecked();
183
+ await expect(description.locator('input[type="checkbox"]').nth(1)).toBeDisabled();
184
+ expect(webPageErrors).toEqual([]);
185
+ expect(webConsoleErrors).toEqual([]);
186
+ } finally {
187
+ if (skuId && headers) {
188
+ const response = await adminPage.request.delete(`${skuActionPath}/${skuId}`, { headers });
189
+ expect(response.ok()).toBeTruthy();
190
+ }
191
+ if (productId && headers) {
192
+ const response = await adminPage.request.delete(`${productActionPath}/${productId}`, {
193
+ headers,
194
+ });
195
+ expect(response.ok()).toBeTruthy();
196
+ }
197
+ if (categoryId && headers) {
198
+ const response = await adminPage.request.delete(`${categoryActionPath}/${categoryId}`, {
199
+ headers,
200
+ });
201
+ expect(response.ok()).toBeTruthy();
202
+ }
203
+ await webContext.close().catch(() => {});
204
+ await adminContext.close().catch(() => {});
205
+ }
206
+ },
207
+ );
208
+
209
+ test(
210
+ 'ATP-SPC-02: Product Content opens the rich-text editor and saves Markdown',
211
+ { tag: ['@admin', '@flow', '@product'] },
212
+ async ({ browser }, testInfo) => {
213
+ test.setTimeout(60_000);
214
+ const suffix = `${testInfo.workerIndex}-${testInfo.parallelIndex ?? testInfo.retry}-${Date.now()}`;
215
+ const categoryName = `E2E Product Content Category ${suffix}`;
216
+ const productTitle = `E2E Product Content ${suffix}`;
217
+ const markdown = `# Product content ${suffix}
218
+
219
+ - First benefit
220
+ - Second benefit
221
+
222
+ - [ ] Open task
223
+ - [x] Completed task
224
+
225
+ > Product quote
226
+
227
+ \`\`\`ts
228
+ const product = '${suffix}';
229
+ \`\`\`
230
+
231
+ ==highlighted text==
232
+
233
+
234
+ | Feature | Value |
235
+ | -------- | ------- |
236
+ | Material | Durable |`;
237
+ const adminContext = await browser.newContext();
238
+ const adminPage = await adminContext.newPage();
239
+ const adminPageErrors = collectPageErrors(adminPage);
240
+ const adminConsoleErrors = collectConsoleErrors(adminPage);
241
+ const productListPath = '/commerce-admin/rest/resource/commerce-catalog%3Aproduct';
242
+ const categoryActionPath = '/api/commerce/catalog/category';
243
+ const productActionPath = '/api/commerce/catalog/product';
244
+ let categoryId: number | string | undefined;
245
+ let productId: number | string | undefined;
246
+ let headers: { Authorization: string } | undefined;
247
+ try {
248
+ await adminPage.setViewportSize({ width: 1440, height: 900 });
249
+ await login(adminPage, '/commerce-admin/', 'admin', '123456', 'commerceAdmin');
250
+ const accessToken = (await adminContext.cookies()).find(
251
+ cookie => cookie.name === 'token',
252
+ )?.value;
253
+ expect(accessToken).toBeTruthy();
254
+ headers = { Authorization: `Bearer ${accessToken}` };
255
+
256
+ const categoryResponse = await adminPage.request.post(categoryActionPath, {
257
+ data: { name: categoryName, published: false },
258
+ headers,
259
+ });
260
+ expect(categoryResponse.ok()).toBeTruthy();
261
+ categoryId = (await categoryResponse.json()).data;
262
+ expectTableIdentity(categoryId);
263
+
264
+ const productResponse = await adminPage.request.post(productActionPath, {
265
+ data: {
266
+ categoryId,
267
+ title: productTitle,
268
+ published: false,
269
+ productContentForm: { descriptionMarkdown: markdown },
270
+ },
271
+ headers,
272
+ });
273
+ expect(productResponse.ok()).toBeTruthy();
274
+ productId = (await productResponse.json()).data;
275
+ expectTableIdentity(productId);
276
+
277
+ await adminPage.goto(`${productListPath}/${productId}/edit`, { waitUntil: 'load' });
278
+ await expect(adminPage.locator('html')).toHaveAttribute(
279
+ 'data-zova-hydrated',
280
+ 'commerceAdmin',
281
+ );
282
+ expect(adminPageErrors).toEqual([]);
283
+ expect(adminConsoleErrors).toEqual([]);
284
+ const editor = adminPage.locator('[contenteditable="true"]');
285
+ await expect(editor).toBeVisible();
286
+ const toolbar = adminPage.getByRole('toolbar', { name: 'Markdown toolbar' });
287
+ await expect(toolbar).toBeVisible();
288
+ const undoButton = toolbar.getByRole('button', { name: 'Undo' });
289
+ await expect(toolbar.getByRole('combobox', { name: 'Code block language' })).toHaveCount(0);
290
+ const codeBlockToolbar = adminPage.getByRole('toolbar', { name: 'Code block language' });
291
+ await expect(codeBlockToolbar).toBeHidden();
292
+ await expect(toolbar.getByRole('button', { name: 'Bold' })).toHaveAttribute('type', 'button');
293
+ await expect(undoButton).toBeEnabled();
294
+ const insertTableButton = toolbar.getByRole('button', { name: 'Insert table' });
295
+ await expect(insertTableButton).toHaveAttribute('type', 'button');
296
+ await expect(insertTableButton).toHaveAttribute('aria-haspopup', 'grid');
297
+ await expect(insertTableButton).toHaveAttribute('aria-expanded', 'false');
298
+ await insertTableButton.click();
299
+ const tablePickerDialog = adminPage.locator('#markdown-table-size-picker');
300
+ const tablePicker = tablePickerDialog.locator('[role="grid"]');
301
+ await expect(tablePickerDialog).toBeVisible();
302
+ await expect(tablePicker).toHaveCount(1);
303
+ await expect(insertTableButton).toHaveAttribute('aria-expanded', 'true');
304
+ await expect(editor.locator('table')).toHaveCount(1);
305
+ const tablePickerCell = tablePicker.locator('[data-table-picker-cell="2-3"]');
306
+ await tablePickerCell.hover();
307
+ await expect(tablePickerDialog.locator('[aria-live="polite"]')).toHaveText(
308
+ 'Selected table size: 2 × 3',
309
+ );
310
+ await expect(tablePicker.locator('[aria-selected="true"]')).toHaveCount(6);
311
+ await expect(tablePicker.locator('[aria-selected="false"]').first()).toBeVisible();
312
+ await tablePickerCell.click();
313
+ await expect(tablePickerDialog).toHaveCount(0);
314
+ await expect(insertTableButton).toHaveAttribute('aria-expanded', 'false');
315
+ await expect(editor.locator('table')).toHaveCount(2);
316
+ await expect(editor.locator('table').last().locator('th')).toHaveCount(3);
317
+ await expect(editor.locator('table').last().locator('td')).toHaveCount(3);
318
+ const editorSurface = editor.locator('..');
319
+ await editor.locator('h1').click();
320
+ await expect(editorSurface).not.toHaveClass(/\bprose\b/);
321
+ const editorSurfaceClass = await editorSurface.getAttribute('class');
322
+ expect(editorSurfaceClass?.trim()).toBeTruthy();
323
+ await expect(editor).toBeFocused();
324
+ await expect(editor.locator('h1')).toHaveText(`Product content ${suffix}`);
325
+ await expect(editor.locator('ul:not([data-type="taskList"])')).toHaveCount(1);
326
+ await expect(editor.locator('blockquote')).toHaveText('Product quote');
327
+ const codeBlock = editor.locator('pre code');
328
+ const codeBlockLanguage = codeBlockToolbar.getByRole('combobox', {
329
+ name: 'Code block language',
330
+ });
331
+ await expect(codeBlockToolbar).toBeHidden();
332
+ await expect(codeBlock).toHaveClass(/\blanguage-ts\b/);
333
+ await expect(codeBlock).toHaveText(`const product = '${suffix}';`);
334
+ await expect(codeBlock.locator('span[class*="hljs-"]')).not.toHaveCount(0);
335
+ await codeBlock.click();
336
+ await expect(codeBlockToolbar).toBeVisible();
337
+ await expect(codeBlockLanguage).toBeEnabled();
338
+ await expect(codeBlockLanguage).toHaveValue('typescript');
339
+ await codeBlockLanguage.selectOption('python');
340
+ await expect(codeBlock).toHaveClass(/\blanguage-python\b/);
341
+ await expect(codeBlock.locator('span[class*="hljs-"]')).not.toHaveCount(0);
342
+ await editor.locator('h1').click();
343
+ await expect(codeBlockToolbar).toBeHidden();
344
+ await expect(editor.locator('mark')).toHaveText('highlighted text');
345
+ await expect(editor.locator('table th')).toHaveCount(5);
346
+ await expect(editor.locator('table td')).toHaveCount(5);
347
+ await expect(editor.locator('h1')).toHaveCSS('font-weight', '700');
348
+ await expect(editor.locator('ul:not([data-type="taskList"])')).toHaveCSS(
349
+ 'list-style-type',
350
+ 'disc',
351
+ );
352
+ await expect(editor.locator('ul[data-type="taskList"]')).toHaveCount(1);
353
+ await expect(editor.locator('input[type="checkbox"]')).toHaveCount(2);
354
+ await expect(editor.locator('input[type="checkbox"]').nth(0)).not.toBeChecked();
355
+ await expect(editor.locator('input[type="checkbox"]').nth(1)).toBeChecked();
356
+ await expect(editor.locator('blockquote')).toHaveCSS('border-left-width', '4px');
357
+ await expect(editor.locator('pre')).toHaveCSS('overflow-x', 'auto');
358
+ await expect(editor.locator('table th').first()).toHaveCSS('font-weight', '600');
359
+ await editor.locator('h1').selectText();
360
+ const boldButton = toolbar.getByRole('button', { name: 'Bold' });
361
+ await boldButton.click();
362
+ await expect(boldButton).toHaveAttribute('aria-pressed', 'true');
363
+ await undoButton.click();
364
+ await expect(boldButton).toHaveAttribute('aria-pressed', 'false');
365
+ await expect(toolbar.getByRole('button', { name: 'Redo' })).toBeEnabled();
366
+
367
+ const tableToolbar = adminPage.getByRole('toolbar', { name: 'Table toolbar' });
368
+ const insertedTable = editor.locator('table').last();
369
+ await editor.locator('h1').click();
370
+ await expect(tableToolbar).toBeHidden();
371
+ await insertedTable.locator('td').first().click();
372
+ expect(adminPageErrors).toEqual([]);
373
+ expect(adminConsoleErrors).toEqual([]);
374
+ await expect(tableToolbar).toBeVisible();
375
+ expect(adminPageErrors).toEqual([]);
376
+ expect(adminConsoleErrors).toEqual([]);
377
+ await expect(tableToolbar.getByRole('button', { name: 'Add row before' })).toHaveAttribute(
378
+ 'type',
379
+ 'button',
380
+ );
381
+ await tableToolbar.getByRole('button', { name: 'Add row before' }).click();
382
+ await expect(insertedTable.locator('td')).toHaveCount(6);
383
+ expect(adminPageErrors).toEqual([]);
384
+ await tableToolbar.getByRole('button', { name: 'Delete row' }).click();
385
+ await expect(insertedTable.locator('td')).toHaveCount(3);
386
+ expect(adminPageErrors).toEqual([]);
387
+ await tableToolbar.getByRole('button', { name: 'Add row after' }).click();
388
+ await expect(insertedTable.locator('td')).toHaveCount(6);
389
+ expect(adminPageErrors).toEqual([]);
390
+ await tableToolbar.getByRole('button', { name: 'Delete row' }).click();
391
+ await expect(insertedTable.locator('td')).toHaveCount(3);
392
+ expect(adminPageErrors).toEqual([]);
393
+ await tableToolbar.getByRole('button', { name: 'Add column before' }).click();
394
+ await expect(insertedTable.locator('th')).toHaveCount(4);
395
+ await expect(insertedTable.locator('td')).toHaveCount(4);
396
+ expect(adminPageErrors).toEqual([]);
397
+ await tableToolbar.getByRole('button', { name: 'Delete column' }).click();
398
+ await expect(insertedTable.locator('th')).toHaveCount(3);
399
+ await expect(insertedTable.locator('td')).toHaveCount(3);
400
+ expect(adminPageErrors).toEqual([]);
401
+ await tableToolbar.getByRole('button', { name: 'Add column after' }).click();
402
+ await expect(insertedTable.locator('th')).toHaveCount(4);
403
+ await expect(insertedTable.locator('td')).toHaveCount(4);
404
+ expect(adminPageErrors).toEqual([]);
405
+ await tableToolbar.getByRole('button', { name: 'Delete column' }).click();
406
+ await expect(insertedTable.locator('th')).toHaveCount(3);
407
+ await expect(insertedTable.locator('td')).toHaveCount(3);
408
+ expect(adminPageErrors).toEqual([]);
409
+ await tableToolbar.getByRole('button', { name: 'Delete table' }).click();
410
+ await expect(editor.locator('table')).toHaveCount(1);
411
+ await expect(tableToolbar).toBeHidden();
412
+ expect(adminPageErrors).toEqual([]);
413
+
414
+ const productUpdate = waitForApiResponse(
415
+ adminPage,
416
+ 'PATCH',
417
+ `${productActionPath}/${productId}`,
418
+ );
419
+ await adminPage.getByRole('button', { name: 'Submit', exact: true }).click();
420
+ await productUpdate;
421
+ const productResponseAfterUpdate = await adminPage.request.get(
422
+ `${productActionPath}/${productId}`,
423
+ { headers },
424
+ );
425
+ expect(productResponseAfterUpdate.ok()).toBeTruthy();
426
+ expect((await productResponseAfterUpdate.json()).productContentForm.descriptionMarkdown).toBe(
427
+ markdown.replace('```ts', '```python'),
428
+ );
429
+ expect(adminPageErrors).toEqual([]);
430
+ } finally {
431
+ if (productId && headers) {
432
+ const response = await adminPage.request.delete(`${productActionPath}/${productId}`, {
433
+ headers,
434
+ });
435
+ expect(response.ok()).toBeTruthy();
436
+ }
437
+ if (categoryId && headers) {
438
+ const response = await adminPage.request.delete(`${categoryActionPath}/${categoryId}`, {
439
+ headers,
440
+ });
441
+ expect(response.ok()).toBeTruthy();
442
+ }
443
+ await adminContext.close().catch(() => {});
444
+ }
445
+ },
446
+ );
447
+
448
+ test(
449
+ 'ATP-SPC-02: Product Content link toolbar edits Markdown',
450
+ { tag: ['@admin', '@flow', '@product'] },
451
+ async ({ browser }, testInfo) => {
452
+ test.setTimeout(180_000);
453
+ const suffix = `${testInfo.workerIndex}-${testInfo.parallelIndex ?? testInfo.retry}-${Date.now()}`;
454
+ const categoryName = `E2E Product Link Category ${suffix}`;
455
+ const productTitle = `E2E Product Link ${suffix}`;
456
+ const markdown = `# Product content ${suffix}\n\nA product description.`;
457
+ const adminContext = await browser.newContext();
458
+ const adminPage = await adminContext.newPage();
459
+ const adminPageErrors = collectPageErrors(adminPage);
460
+ const adminConsoleErrors = collectConsoleErrors(adminPage);
461
+ const productListPath = '/commerce-admin/rest/resource/commerce-catalog%3Aproduct';
462
+ const categoryActionPath = '/api/commerce/catalog/category';
463
+ const productActionPath = '/api/commerce/catalog/product';
464
+ let categoryId: number | string | undefined;
465
+ let productId: number | string | undefined;
466
+ let headers: { Authorization: string } | undefined;
467
+ try {
468
+ await adminPage.setViewportSize({ width: 1440, height: 900 });
469
+ await login(adminPage, '/commerce-admin/', 'admin', '123456', 'commerceAdmin');
470
+ const accessToken = (await adminContext.cookies()).find(
471
+ cookie => cookie.name === 'token',
472
+ )?.value;
473
+ expect(accessToken).toBeTruthy();
474
+ headers = { Authorization: `Bearer ${accessToken}` };
475
+
476
+ const categoryResponse = await adminPage.request.post(categoryActionPath, {
477
+ data: { name: categoryName, published: false },
478
+ headers,
479
+ });
480
+ expect(categoryResponse.ok()).toBeTruthy();
481
+ categoryId = (await categoryResponse.json()).data;
482
+ expectTableIdentity(categoryId);
483
+
484
+ const productResponse = await adminPage.request.post(productActionPath, {
485
+ data: {
486
+ categoryId,
487
+ title: productTitle,
488
+ published: false,
489
+ productContentForm: { descriptionMarkdown: markdown },
490
+ },
491
+ headers,
492
+ });
493
+ expect(productResponse.ok()).toBeTruthy();
494
+ productId = (await productResponse.json()).data;
495
+ expectTableIdentity(productId);
496
+
497
+ await adminPage.goto(`${productListPath}/${productId}/edit`, { waitUntil: 'load' });
498
+ await expect(adminPage.locator('html')).toHaveAttribute(
499
+ 'data-zova-hydrated',
500
+ 'commerceAdmin',
501
+ );
502
+ const editor = adminPage.locator('[contenteditable="true"]');
503
+ await expect(editor).toBeVisible();
504
+ const toolbar = adminPage.getByRole('toolbar', { name: 'Markdown toolbar' });
505
+ await expect(toolbar).toBeVisible();
506
+ const linkButton = toolbar.getByRole('button', { name: 'Link' });
507
+ await expect(linkButton).toHaveAttribute('type', 'button');
508
+ await expect(linkButton).toBeDisabled();
509
+
510
+ await editor.locator('h1').selectText();
511
+ await expect(linkButton).toBeEnabled();
512
+ await linkButton.click();
513
+ const linkPrompt = adminPage.locator('.fixed.inset-0.z-50');
514
+ await expect(linkPrompt).toBeVisible();
515
+ const linkInput = linkPrompt.locator('input[type="text"]');
516
+ await linkInput.fill('https://example.com/docs');
517
+ await linkInput.press('Enter');
518
+ await expect(editor.locator('h1 a[href="https://example.com/docs"]')).toHaveText(
519
+ `Product content ${suffix}`,
520
+ );
521
+ await expect(linkButton).toHaveAttribute('aria-pressed', 'true');
522
+ await editor.press('ArrowLeft');
523
+ await expect(linkButton).toHaveAttribute('aria-pressed', 'true');
524
+
525
+ await linkButton.click();
526
+ await expect(linkPrompt).toBeVisible();
527
+ await expect(linkInput).toHaveValue('https://example.com/docs');
528
+ await linkInput.fill('https://example.com/updated');
529
+ await linkPrompt.getByRole('button', { name: 'OK' }).click();
530
+ await expect(editor.locator('h1 a[href="https://example.com/updated"]')).toHaveText(
531
+ `Product content ${suffix}`,
532
+ );
533
+
534
+ await linkButton.click();
535
+ await expect(linkPrompt).toBeVisible();
536
+ await linkPrompt.getByRole('button', { name: 'Cancel' }).click();
537
+ await expect(editor.locator('h1 a[href="https://example.com/updated"]')).toHaveText(
538
+ `Product content ${suffix}`,
539
+ );
540
+
541
+ await editor.locator('h1').selectText();
542
+ await expect(linkButton).toHaveAttribute('aria-pressed', 'true');
543
+ await linkButton.click();
544
+ await expect(linkPrompt).toBeVisible();
545
+ await linkInput.fill('');
546
+ await linkInput.press('Enter');
547
+ await expect(editor.locator('h1 a')).toHaveCount(0);
548
+ await adminPage.getByRole('button', { name: 'Submit', exact: true }).click();
549
+ const productResponseAfterLinkRemoval = await adminPage.request.get(
550
+ `${productActionPath}/${productId}`,
551
+ { headers },
552
+ );
553
+ expect(productResponseAfterLinkRemoval.ok()).toBeTruthy();
554
+ expect(
555
+ (await productResponseAfterLinkRemoval.json()).productContentForm.descriptionMarkdown,
556
+ ).not.toContain('https://example.com/updated');
557
+ expect(adminPageErrors).toEqual([]);
558
+ expect(adminConsoleErrors).toEqual([]);
559
+ } finally {
560
+ if (productId && headers) {
561
+ const response = await adminPage.request.delete(`${productActionPath}/${productId}`, {
562
+ headers,
563
+ });
564
+ expect(response.ok()).toBeTruthy();
565
+ }
566
+ if (categoryId && headers) {
567
+ const response = await adminPage.request.delete(`${categoryActionPath}/${categoryId}`, {
568
+ headers,
569
+ });
570
+ expect(response.ok()).toBeTruthy();
571
+ }
572
+ await adminContext.close().catch(() => {});
573
+ }
574
+ },
575
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.144",
3
+ "version": "5.1.145",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -43,11 +43,11 @@
43
43
  "build:zova:commerce": "pnpm --dir zova run build:ssr:cabloyCommerceBatch",
44
44
  "build:zova:commerce:web": "pnpm --dir zova run build:ssr:cabloyCommerce && pnpm --dir zova run build:rest:cabloyCommerce",
45
45
  "build:zova:commerce:admin": "pnpm --dir zova run build:ssr:cabloyCommerceAdmin && pnpm --dir zova run build:rest:cabloyCommerceAdmin",
46
- "test:e2e": "node e2e/scripts/runE2e.ts --clean",
47
- "test:e2e:fast": "node e2e/scripts/runE2e.ts --fast",
48
46
  "start": "cd vona && npm run start",
49
47
  "start:one": "cd vona && npm run start:one",
50
48
  "test": "cd vona && npm run test",
49
+ "test:e2e": "node e2e/scripts/runE2e.ts --clean",
50
+ "test:e2e:fast": "node e2e/scripts/runE2e.ts --fast",
51
51
  "db:reset": "cd vona && npm run db:reset",
52
52
  "tsc": "npm run tsc:zova && pnpm --dir vona run tsc",
53
53
  "tsc:zova": "pnpm --dir zova run tsc",
@@ -1687,6 +1687,9 @@ importers:
1687
1687
  '@tiptap/static-renderer':
1688
1688
  specifier: ^3.30.0
1689
1689
  version: 3.30.0(@tiptap/core@3.30.0(@tiptap/pm@3.30.0))(@tiptap/pm@3.30.0)(react-dom@19.2.8(react@19.2.7))(react@19.2.7)
1690
+ lowlight:
1691
+ specifier: ^3.3.0
1692
+ version: 3.3.0
1690
1693
  sanitize-html:
1691
1694
  specifier: ^2.17.6
1692
1695
  version: 2.17.6
@@ -5143,6 +5146,9 @@ packages:
5143
5146
  '@types/fs-extra@11.0.4':
5144
5147
  resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
5145
5148
 
5149
+ '@types/hast@3.0.5':
5150
+ resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
5151
+
5146
5152
  '@types/http-assert@1.5.6':
5147
5153
  resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
5148
5154
 
@@ -6607,6 +6613,10 @@ packages:
6607
6613
  header-case@2.0.4:
6608
6614
  resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==}
6609
6615
 
6616
+ highlight.js@11.11.2:
6617
+ resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==}
6618
+ engines: {node: '>=12.0.0'}
6619
+
6610
6620
  hono@4.12.32:
6611
6621
  resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==}
6612
6622
  engines: {node: '>=16.9.0'}
@@ -7167,6 +7177,9 @@ packages:
7167
7177
  lower-case@2.0.2:
7168
7178
  resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
7169
7179
 
7180
+ lowlight@3.3.0:
7181
+ resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
7182
+
7170
7183
  lru-cache@10.4.3:
7171
7184
  resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
7172
7185
 
@@ -10967,6 +10980,10 @@ snapshots:
10967
10980
  '@types/jsonfile': 6.1.4
10968
10981
  '@types/node': 22.20.0
10969
10982
 
10983
+ '@types/hast@3.0.5':
10984
+ dependencies:
10985
+ '@types/unist': 3.0.3
10986
+
10970
10987
  '@types/http-assert@1.5.6': {}
10971
10988
 
10972
10989
  '@types/http-errors@2.0.5': {}
@@ -12562,6 +12579,8 @@ snapshots:
12562
12579
  capital-case: 1.0.4
12563
12580
  tslib: 2.8.1
12564
12581
 
12582
+ highlight.js@11.11.2: {}
12583
+
12565
12584
  hono@4.12.32: {}
12566
12585
 
12567
12586
  hookified@1.15.1: {}
@@ -13103,6 +13122,12 @@ snapshots:
13103
13122
  dependencies:
13104
13123
  tslib: 2.8.1
13105
13124
 
13125
+ lowlight@3.3.0:
13126
+ dependencies:
13127
+ '@types/hast': 3.0.5
13128
+ devlop: 1.1.0
13129
+ highlight.js: 11.11.2
13130
+
13106
13131
  lru-cache@10.4.3: {}
13107
13132
 
13108
13133
  lru-cache@11.5.1: {}