@open-mercato/core 0.4.9-develop-e55592929f → 0.4.9-develop-ce96cffe00

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 (83) hide show
  1. package/dist/helpers/integration/api.js +66 -0
  2. package/dist/helpers/integration/api.js.map +7 -0
  3. package/dist/helpers/integration/apiKeysFixtures.js +16 -0
  4. package/dist/helpers/integration/apiKeysFixtures.js.map +7 -0
  5. package/dist/helpers/integration/attachmentsFixtures.js +61 -0
  6. package/dist/helpers/integration/attachmentsFixtures.js.map +7 -0
  7. package/dist/helpers/integration/auth.js +190 -0
  8. package/dist/helpers/integration/auth.js.map +7 -0
  9. package/dist/helpers/integration/authFixtures.js +39 -0
  10. package/dist/helpers/integration/authFixtures.js.map +7 -0
  11. package/dist/helpers/integration/authUi.js +31 -0
  12. package/dist/helpers/integration/authUi.js.map +7 -0
  13. package/dist/helpers/integration/businessRulesFixtures.js +40 -0
  14. package/dist/helpers/integration/businessRulesFixtures.js.map +7 -0
  15. package/dist/helpers/integration/catalogFixtures.js +49 -0
  16. package/dist/helpers/integration/catalogFixtures.js.map +7 -0
  17. package/dist/helpers/integration/crmFixtures.js +91 -0
  18. package/dist/helpers/integration/crmFixtures.js.map +7 -0
  19. package/dist/helpers/integration/currenciesFixtures.js +39 -0
  20. package/dist/helpers/integration/currenciesFixtures.js.map +7 -0
  21. package/dist/helpers/integration/dictionariesFixtures.js +16 -0
  22. package/dist/helpers/integration/dictionariesFixtures.js.map +7 -0
  23. package/dist/helpers/integration/featureTogglesFixtures.js +23 -0
  24. package/dist/helpers/integration/featureTogglesFixtures.js.map +7 -0
  25. package/dist/helpers/integration/generalFixtures.js +56 -0
  26. package/dist/helpers/integration/generalFixtures.js.map +7 -0
  27. package/dist/helpers/integration/inboxFixtures.js +67 -0
  28. package/dist/helpers/integration/inboxFixtures.js.map +7 -0
  29. package/dist/helpers/integration/notificationsFixtures.js +48 -0
  30. package/dist/helpers/integration/notificationsFixtures.js.map +7 -0
  31. package/dist/helpers/integration/salesFixtures.js +63 -0
  32. package/dist/helpers/integration/salesFixtures.js.map +7 -0
  33. package/dist/helpers/integration/salesUi.js +827 -0
  34. package/dist/helpers/integration/salesUi.js.map +7 -0
  35. package/dist/helpers/integration/sseEventCollector.js +27 -0
  36. package/dist/helpers/integration/sseEventCollector.js.map +7 -0
  37. package/dist/helpers/integration/staffFixtures.js +47 -0
  38. package/dist/helpers/integration/staffFixtures.js.map +7 -0
  39. package/dist/testing/integration/api.js +2 -0
  40. package/dist/testing/integration/api.js.map +7 -0
  41. package/dist/testing/integration/auth.js +2 -0
  42. package/dist/testing/integration/auth.js.map +7 -0
  43. package/dist/testing/integration/authFixtures.js +2 -0
  44. package/dist/testing/integration/authFixtures.js.map +7 -0
  45. package/dist/testing/integration/authUi.js +2 -0
  46. package/dist/testing/integration/authUi.js.map +7 -0
  47. package/dist/testing/integration/crmFixtures.js +2 -0
  48. package/dist/testing/integration/crmFixtures.js.map +7 -0
  49. package/dist/testing/integration/dictionariesFixtures.js +2 -0
  50. package/dist/testing/integration/dictionariesFixtures.js.map +7 -0
  51. package/dist/testing/integration/generalFixtures.js +2 -0
  52. package/dist/testing/integration/generalFixtures.js.map +7 -0
  53. package/dist/testing/integration/index.js +48 -0
  54. package/dist/testing/integration/index.js.map +7 -0
  55. package/package.json +11 -3
  56. package/src/helpers/integration/api.ts +87 -0
  57. package/src/helpers/integration/apiKeysFixtures.ts +17 -0
  58. package/src/helpers/integration/attachmentsFixtures.ts +114 -0
  59. package/src/helpers/integration/auth.ts +208 -0
  60. package/src/helpers/integration/authFixtures.ts +52 -0
  61. package/src/helpers/integration/authUi.ts +33 -0
  62. package/src/helpers/integration/businessRulesFixtures.ts +53 -0
  63. package/src/helpers/integration/catalogFixtures.ts +73 -0
  64. package/src/helpers/integration/crmFixtures.ts +132 -0
  65. package/src/helpers/integration/currenciesFixtures.ts +49 -0
  66. package/src/helpers/integration/dictionariesFixtures.ts +17 -0
  67. package/src/helpers/integration/featureTogglesFixtures.ts +28 -0
  68. package/src/helpers/integration/generalFixtures.ts +71 -0
  69. package/src/helpers/integration/inboxFixtures.ts +94 -0
  70. package/src/helpers/integration/notificationsFixtures.ts +67 -0
  71. package/src/helpers/integration/salesFixtures.ts +89 -0
  72. package/src/helpers/integration/salesUi.ts +936 -0
  73. package/src/helpers/integration/sseEventCollector.ts +30 -0
  74. package/src/helpers/integration/staffFixtures.ts +61 -0
  75. package/src/testing/integration/api.ts +1 -0
  76. package/src/testing/integration/auth.ts +1 -0
  77. package/src/testing/integration/authFixtures.ts +1 -0
  78. package/src/testing/integration/authUi.ts +1 -0
  79. package/src/testing/integration/crmFixtures.ts +1 -0
  80. package/src/testing/integration/dictionariesFixtures.ts +1 -0
  81. package/src/testing/integration/generalFixtures.ts +1 -0
  82. package/src/testing/integration/index.ts +22 -0
  83. package/tsconfig.json +3 -0
@@ -0,0 +1,936 @@
1
+ import { expect, type Locator, type Page } from '@playwright/test';
2
+ import { apiRequest, getAuthToken } from './api';
3
+
4
+ type DocumentKind = 'quote' | 'order';
5
+
6
+ type CreateDocumentOptions = {
7
+ kind: DocumentKind;
8
+ customerQuery?: string;
9
+ channelQuery?: string;
10
+ };
11
+
12
+ type ChannelListItem = {
13
+ id?: string | null;
14
+ name?: string | null;
15
+ code?: string | null;
16
+ isActive?: boolean | null;
17
+ };
18
+
19
+ type AddLineOptions = {
20
+ name: string;
21
+ quantity: number;
22
+ unitPriceGross: number;
23
+ taxClassName?: string;
24
+ };
25
+
26
+ type AddAdjustmentOptions = {
27
+ label: string;
28
+ kindLabel?: string;
29
+ netAmount: number;
30
+ };
31
+
32
+ const TEST_WAIT_TIMEOUT_MS = 10_000;
33
+
34
+ function escapeRegExp(value: string): string {
35
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ }
37
+
38
+ function parseCurrencyAmount(value: string): number {
39
+ const normalized = value.replace(/,/g, '');
40
+ const matches = normalized.match(/-?\$[0-9]+(?:\.[0-9]{2})?/g);
41
+ const lastMatch = matches?.[matches.length - 1];
42
+ if (!lastMatch) {
43
+ throw new Error(`Could not parse currency from: ${value}`);
44
+ }
45
+ return Number.parseFloat(lastMatch.replace('$', ''));
46
+ }
47
+
48
+ function normalizeAdjustmentKindValue(kindLabel: string): string {
49
+ return kindLabel.trim().toLowerCase().replace(/\s+/g, '_');
50
+ }
51
+
52
+ function readId(payload: unknown, keys: string[]): string | null {
53
+ if (!payload || typeof payload !== 'object') return null;
54
+ const map = payload as Record<string, unknown>;
55
+ for (const key of keys) {
56
+ const value = map[key];
57
+ if (typeof value === 'string' && value.length > 0) return value;
58
+ }
59
+ for (const value of Object.values(map)) {
60
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
61
+ const nested = readId(value, keys);
62
+ if (nested) return nested;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ async function ensureSalesDocumentFixtures(
69
+ page: Page,
70
+ options: CreateDocumentOptions,
71
+ ): Promise<{ customerQuery: string; channelQuery: string }> {
72
+ let customerQuery = options.customerQuery;
73
+ let channelQuery = options.channelQuery;
74
+
75
+ if (customerQuery && channelQuery) {
76
+ return { customerQuery, channelQuery };
77
+ }
78
+
79
+ const token = await getAuthToken(page.request, 'admin').catch(() => null);
80
+ if (!token) {
81
+ if (!customerQuery) {
82
+ customerQuery = `QA Sales Customer ${Date.now()}`;
83
+ await page.goto('/backend/customers/companies/create');
84
+ await page.locator('form').getByRole('textbox').first().fill(customerQuery);
85
+ await page.getByPlaceholder('https://example.com').fill('https://example.com');
86
+ await page.locator('form').getByRole('button', { name: /Create Company/i }).click();
87
+ await expect(page).toHaveURL(/\/backend\/customers\/companies\/[0-9a-f-]{36}$/i);
88
+ }
89
+ if (!channelQuery) {
90
+ const timestamp = Date.now();
91
+ channelQuery = `QA Sales Channel ${timestamp}`;
92
+ const channelCode = `qa-sales-channel-${timestamp}`;
93
+ await page.goto('/backend/sales/channels');
94
+ await page.getByRole('link', { name: /Add channel/i }).click();
95
+ const createForm = page.locator('form').first();
96
+ await createForm.getByRole('textbox').nth(0).fill(channelQuery);
97
+ await createForm.getByRole('textbox').nth(1).fill(channelCode);
98
+ await page.getByRole('button', { name: /Create channel|Create/i }).last().click();
99
+ await expect(page).toHaveURL(/\/backend\/sales\/channels$/i);
100
+ }
101
+ return {
102
+ customerQuery,
103
+ channelQuery,
104
+ };
105
+ }
106
+
107
+ if (!customerQuery) {
108
+ const companyName = `QA Sales Customer ${Date.now()}`;
109
+ const companyResponse = await apiRequest(page.request, 'POST', '/api/customers/companies', {
110
+ token,
111
+ data: { displayName: companyName },
112
+ }).catch(() => null);
113
+ if (companyResponse && companyResponse.ok()) {
114
+ const companyBody = (await companyResponse.json().catch(() => null)) as unknown;
115
+ const companyId = readId(companyBody, ['id', 'entityId', 'companyId']);
116
+ if (companyId) {
117
+ await apiRequest(page.request, 'POST', '/api/customers/addresses', {
118
+ token,
119
+ data: {
120
+ entityId: companyId,
121
+ name: 'Primary',
122
+ purpose: 'Shipping',
123
+ addressLine1: '100 QA Street',
124
+ city: 'Austin',
125
+ postalCode: '78701',
126
+ country: 'US',
127
+ isPrimary: true,
128
+ },
129
+ }).catch(() => {});
130
+ customerQuery = companyName;
131
+ }
132
+ }
133
+ if (!customerQuery) {
134
+ customerQuery = 'Copperleaf';
135
+ }
136
+ }
137
+
138
+ if (!channelQuery) {
139
+ const existingChannelsResponse = await apiRequest(
140
+ page.request,
141
+ 'GET',
142
+ '/api/sales/channels?page=1&pageSize=20&isActive=true',
143
+ { token },
144
+ ).catch(() => null);
145
+ const existingChannelsBody = (await existingChannelsResponse?.json().catch(() => null)) as { items?: ChannelListItem[] } | null;
146
+ const existingChannels = Array.isArray(existingChannelsBody?.items) ? existingChannelsBody.items : [];
147
+ const preferredExistingChannel =
148
+ existingChannels.find((item) => item.code === 'online' && item.isActive !== false) ??
149
+ existingChannels.find((item) => item.isActive !== false);
150
+ if (preferredExistingChannel?.name) {
151
+ channelQuery = preferredExistingChannel.name;
152
+ } else {
153
+ const timestamp = Date.now();
154
+ const channelName = `QA Sales Channel ${timestamp}`;
155
+ const channelCode = `qa-sales-channel-${timestamp}`;
156
+ const channelResponse = await apiRequest(page.request, 'POST', '/api/sales/channels', {
157
+ token,
158
+ data: {
159
+ name: channelName,
160
+ code: channelCode,
161
+ },
162
+ }).catch(() => null);
163
+ if (channelResponse && channelResponse.ok()) {
164
+ channelQuery = channelName;
165
+ } else {
166
+ channelQuery = 'online';
167
+ }
168
+ }
169
+ }
170
+
171
+ return {
172
+ customerQuery: customerQuery ?? 'Copperleaf',
173
+ channelQuery: channelQuery ?? 'online',
174
+ };
175
+ }
176
+
177
+ async function selectFirstAddressIfAvailable(page: Page): Promise<void> {
178
+ const addressSelect = page
179
+ .locator('select')
180
+ .filter({ has: page.locator('option', { hasText: 'Select address' }) })
181
+ .first();
182
+ if ((await addressSelect.count()) === 0) return;
183
+ if (!(await addressSelect.isEnabled())) return;
184
+
185
+ const nextValue = await addressSelect.evaluate((element) => {
186
+ const select = element as HTMLSelectElement;
187
+ return select.options.length > 1 ? select.options[1]?.value ?? null : null;
188
+ });
189
+ if (nextValue) {
190
+ await addressSelect.selectOption(nextValue);
191
+ }
192
+ }
193
+
194
+ async function ensureShippingMethodFixture(page: Page): Promise<void> {
195
+ const token = await getAuthToken(page.request, 'admin').catch(() => null);
196
+ if (!token) return;
197
+
198
+ const existing = await apiRequest(
199
+ page.request,
200
+ 'GET',
201
+ '/api/sales/shipping-methods?page=1&pageSize=1&isActive=true',
202
+ { token },
203
+ ).catch(() => null);
204
+ const existingBody = (await existing?.json().catch(() => null)) as { result?: { items?: unknown[] } } | null;
205
+ const existingItems = Array.isArray(existingBody?.result?.items) ? existingBody?.result?.items : [];
206
+ if (existingItems.length > 0) return;
207
+
208
+ const stamp = Date.now();
209
+ await apiRequest(page.request, 'POST', '/api/sales/shipping-methods', {
210
+ token,
211
+ data: {
212
+ name: `QA Shipping Method ${stamp}`,
213
+ code: `qa-shipping-${stamp}`,
214
+ isActive: true,
215
+ currencyCode: 'USD',
216
+ baseRateNet: '10.00',
217
+ baseRateGross: '10.00',
218
+ },
219
+ }).catch(() => {});
220
+ }
221
+
222
+ function lookupRootFromInput(input: Locator): Locator {
223
+ return input.locator('xpath=ancestor::div[contains(@class,"space-y-3")][1]');
224
+ }
225
+
226
+ async function waitForLookupIdle(root: Locator): Promise<void> {
227
+ await root
228
+ .getByText(/Searching…|Searching\.\.\.|Loading…|Loading\.\.\./i)
229
+ .first()
230
+ .waitFor({ state: 'hidden', timeout: 1_200 })
231
+ .catch(() => {});
232
+ }
233
+
234
+ async function waitForOptionalTextToDisappear(scope: Locator, pattern: RegExp, timeout = 2_500): Promise<void> {
235
+ await scope.getByText(pattern).first().waitFor({ state: 'hidden', timeout }).catch(() => {});
236
+ }
237
+
238
+ async function waitForStableVisibility(locator: Locator, timeout = TEST_WAIT_TIMEOUT_MS): Promise<void> {
239
+ await expect(locator).toBeVisible({ timeout });
240
+ let stableChecks = 0;
241
+ const deadline = Date.now() + Math.min(timeout, 2_000);
242
+ while (Date.now() < deadline) {
243
+ if (await locator.isVisible().catch(() => false)) {
244
+ stableChecks += 1;
245
+ if (stableChecks >= 3) return;
246
+ } else {
247
+ stableChecks = 0;
248
+ }
249
+ await locator.page().waitForTimeout(50).catch(() => {});
250
+ }
251
+ }
252
+
253
+ async function waitForDialogFieldReady(
254
+ dialog: Locator,
255
+ field: Locator,
256
+ loadingPattern?: RegExp,
257
+ ): Promise<void> {
258
+ await expect(dialog).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
259
+ if (loadingPattern) {
260
+ await waitForOptionalTextToDisappear(dialog, loadingPattern, TEST_WAIT_TIMEOUT_MS);
261
+ }
262
+ await waitForStableVisibility(field, TEST_WAIT_TIMEOUT_MS);
263
+ await field.scrollIntoViewIfNeeded().catch(() => {});
264
+ }
265
+
266
+ async function recoverGenericErrorPageIfPresent(page: Page): Promise<boolean> {
267
+ const errorHeading = page.getByRole('heading', { name: /^Something went wrong$/i }).first();
268
+ if (!(await errorHeading.isVisible().catch(() => false))) return false;
269
+ const retryButton = page.getByRole('button', { name: /Try again/i }).first();
270
+ if (await retryButton.isVisible().catch(() => false)) {
271
+ await retryButton.click().catch(() => {});
272
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
273
+ } else {
274
+ await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {});
275
+ }
276
+ return true;
277
+ }
278
+
279
+ async function resolveCustomerEntityId(
280
+ page: Page,
281
+ token: string,
282
+ customerQuery: string,
283
+ ): Promise<string | null> {
284
+ const params = new URLSearchParams({ page: '1', pageSize: '5', search: customerQuery });
285
+ const response = await apiRequest(page.request, 'GET', `/api/customers/companies?${params.toString()}`, { token }).catch(() => null);
286
+ const body = (await response?.json().catch(() => null)) as { result?: { items?: Array<Record<string, unknown>> } } | null;
287
+ const items = Array.isArray(body?.result?.items) ? body.result.items : [];
288
+ const exactMatch = items.find((item) => {
289
+ const displayName = typeof item.displayName === 'string'
290
+ ? item.displayName
291
+ : typeof item.display_name === 'string'
292
+ ? item.display_name
293
+ : '';
294
+ return displayName.trim().toLowerCase() === customerQuery.trim().toLowerCase();
295
+ }) ?? items[0];
296
+ return exactMatch ? readId(exactMatch, ['id', 'entityId', 'companyId']) : null;
297
+ }
298
+
299
+ async function resolveSalesChannelId(page: Page, token: string, channelQuery: string): Promise<string | null> {
300
+ const resolveItems = async (search?: string): Promise<ChannelListItem[]> => {
301
+ const params = new URLSearchParams({ page: '1', pageSize: '5', isActive: 'true' });
302
+ if (search && search.trim().length > 0) params.set('search', search.trim());
303
+ const response = await apiRequest(page.request, 'GET', `/api/sales/channels?${params.toString()}`, {
304
+ token,
305
+ }).catch(() => null);
306
+ const body = (await response?.json().catch(() => null)) as { result?: { items?: ChannelListItem[] }; items?: ChannelListItem[] } | null;
307
+ return Array.isArray(body?.result?.items)
308
+ ? body.result.items
309
+ : Array.isArray(body?.items)
310
+ ? body.items
311
+ : [];
312
+ };
313
+ const initialItems = await resolveItems(channelQuery);
314
+ const items = initialItems.length > 0 ? initialItems : await resolveItems();
315
+ const normalizedQuery = channelQuery.trim().toLowerCase();
316
+ const exactMatch = items.find((item) => item.name?.trim().toLowerCase() === normalizedQuery)
317
+ ?? items.find((item) => item.code?.trim().toLowerCase() === normalizedQuery)
318
+ ?? items.find((item) => item.code === 'online')
319
+ ?? items[0];
320
+ return exactMatch?.id ?? null;
321
+ }
322
+
323
+ async function createSalesDocumentFixture(
324
+ page: Page,
325
+ token: string,
326
+ kind: DocumentKind,
327
+ customerQuery: string,
328
+ channelQuery: string,
329
+ ): Promise<string> {
330
+ const customerEntityId = await resolveCustomerEntityId(page, token, customerQuery);
331
+ const channelId = await resolveSalesChannelId(page, token, channelQuery);
332
+ const payload: Record<string, unknown> = {
333
+ currencyCode: 'USD',
334
+ };
335
+ if (customerEntityId) payload.customerEntityId = customerEntityId;
336
+ if (channelId) payload.channelId = channelId;
337
+
338
+ const response = await apiRequest(page.request, 'POST', kind === 'quote' ? '/api/sales/quotes' : '/api/sales/orders', {
339
+ token,
340
+ data: payload,
341
+ });
342
+ const body = (await response.json().catch(() => null)) as unknown;
343
+ if (!response.ok()) {
344
+ throw new Error(`Failed to create sales ${kind} fixture via API.`);
345
+ }
346
+ const id = readId(body, ['id', kind === 'quote' ? 'quoteId' : 'orderId']);
347
+ if (!id) {
348
+ throw new Error(`Missing sales ${kind} id in API fallback response.`);
349
+ }
350
+ return id;
351
+ }
352
+
353
+ async function waitForDocumentLoaded(page: Page, timeout = TEST_WAIT_TIMEOUT_MS): Promise<boolean> {
354
+ const itemsButton = page.getByRole('button', { name: /^Items$/i }).first();
355
+ if (await itemsButton.isVisible().catch(() => false)) return true;
356
+ const loadingIndicator = page.getByText(/Loading document…|Loading document\.\.\./i).first();
357
+ const isLoading = await loadingIndicator.isVisible().catch(() => false);
358
+ if (isLoading) {
359
+ await loadingIndicator.waitFor({ state: 'hidden', timeout }).catch(() => {});
360
+ }
361
+ return await itemsButton.waitFor({ state: 'visible', timeout: Math.min(timeout, 5_000) }).then(() => true).catch(() => false);
362
+ }
363
+
364
+ async function openSalesDocumentPage(page: Page, id: string, kind: DocumentKind): Promise<void> {
365
+ const documentUrl = `/backend/sales/documents/${id}?kind=${kind}`;
366
+ await page.goto(documentUrl, { waitUntil: 'domcontentloaded' });
367
+
368
+ if (await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS)) return;
369
+
370
+ for (let attempt = 0; attempt < 3; attempt += 1) {
371
+ const recovered = await recoverGenericErrorPageIfPresent(page);
372
+ if (recovered) {
373
+ if (await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS)) return;
374
+ continue;
375
+ }
376
+ await page.goto(documentUrl, { waitUntil: 'domcontentloaded' });
377
+ if (await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS)) return;
378
+ }
379
+ await expect(page.getByRole('button', { name: /^Items$/i }).first()).toBeVisible({
380
+ timeout: TEST_WAIT_TIMEOUT_MS,
381
+ });
382
+ }
383
+
384
+ async function ensureSalesDocumentReady(page: Page): Promise<void> {
385
+ if (await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS)) return;
386
+
387
+ for (let attempt = 0; attempt < 3; attempt += 1) {
388
+ const recovered = await recoverGenericErrorPageIfPresent(page);
389
+ if (recovered) {
390
+ if (await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS)) return;
391
+ continue;
392
+ }
393
+ await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {});
394
+ if (await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS)) return;
395
+ }
396
+ await expect(page.getByRole('button', { name: /^Items$/i }).first()).toBeVisible({
397
+ timeout: TEST_WAIT_TIMEOUT_MS,
398
+ });
399
+ }
400
+
401
+ async function selectAnyLookupOption(root: Locator): Promise<boolean> {
402
+ const selectButton = root.getByRole('button', { name: /^Select$/i }).first();
403
+ if (await selectButton.isVisible().catch(() => false)) {
404
+ await selectButton.click().catch(() => {});
405
+ return true;
406
+ }
407
+
408
+ const row = root.locator('[role="button"]').first();
409
+ if (await row.isVisible().catch(() => false)) {
410
+ await row.click().catch(() => {});
411
+ return true;
412
+ }
413
+
414
+ return false;
415
+ }
416
+
417
+ async function selectLookupValue(
418
+ input: Locator,
419
+ query: string,
420
+ preferredRowPattern?: RegExp,
421
+ ): Promise<boolean> {
422
+ if (!(await input.isVisible().catch(() => false)) && (await input.count().catch(() => 0)) === 0) return false;
423
+ await waitForStableVisibility(input, 4_000).catch(() => {});
424
+ await input.click().catch(() => {});
425
+ await input.press('ControlOrMeta+a').catch(() => {});
426
+ await input.fill(query).catch(() => {});
427
+
428
+ const root = lookupRootFromInput(input);
429
+ await waitForLookupIdle(root);
430
+
431
+ const selectByPreferredRow = async (): Promise<boolean> => {
432
+ if (!preferredRowPattern) return false;
433
+ const row = root.locator('[role="button"]').filter({ hasText: preferredRowPattern }).first();
434
+ const action = row.getByRole('button', { name: /^Select$/i }).first();
435
+ if (await action.isVisible().catch(() => false)) {
436
+ await action.click();
437
+ return true;
438
+ }
439
+ if ((await row.isVisible().catch(() => false))) {
440
+ await row.click().catch(() => {});
441
+ return true;
442
+ }
443
+ return false;
444
+ };
445
+
446
+ if (await selectByPreferredRow()) return true;
447
+
448
+ const deadline = Date.now() + 4_000;
449
+ while (Date.now() < deadline) {
450
+ await waitForLookupIdle(root);
451
+ if (await selectAnyLookupOption(root)) return true;
452
+ if (await selectByPreferredRow()) return true;
453
+ const selectedButton = root.getByRole('button', { name: /^Selected$/i }).first();
454
+ if (await selectedButton.isVisible().catch(() => false)) {
455
+ return true;
456
+ }
457
+ await input.page().waitForTimeout(250);
458
+ }
459
+
460
+ await input.press('ArrowDown').catch(() => {});
461
+ await input.press('Enter').catch(() => {});
462
+ const selectedButton = root.getByRole('button', { name: /^Selected$/i }).first();
463
+ if (await selectedButton.isVisible().catch(() => false)) {
464
+ return true;
465
+ }
466
+ return await selectAnyLookupOption(root);
467
+ }
468
+
469
+ export async function createSalesDocument(page: Page, options: CreateDocumentOptions): Promise<string> {
470
+ const fixtureContext = await ensureSalesDocumentFixtures(page, options);
471
+ const customerQuery = fixtureContext.customerQuery;
472
+ const channelQuery = fixtureContext.channelQuery;
473
+
474
+ let createPageReady = false;
475
+ for (let attempt = 0; attempt < 3; attempt += 1) {
476
+ await page.goto(`/backend/sales/documents/create?kind=${options.kind}`, {
477
+ waitUntil: 'domcontentloaded',
478
+ });
479
+ await page.getByText(/Loading…|Loading\.\.\./i).first().waitFor({ state: 'hidden', timeout: TEST_WAIT_TIMEOUT_MS }).catch(() => {});
480
+ const createButton = page.getByRole('button', { name: /^Create$/i }).first();
481
+ if (await createButton.isVisible().catch(() => false)) {
482
+ createPageReady = true;
483
+ break;
484
+ }
485
+ const recovered = await recoverGenericErrorPageIfPresent(page);
486
+ if (!recovered && attempt === 2) {
487
+ await expect(createButton).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
488
+ }
489
+ }
490
+ if (!createPageReady) {
491
+ const token = await getAuthToken(page.request, 'admin');
492
+ const id = await createSalesDocumentFixture(page, token, options.kind, customerQuery, channelQuery);
493
+ await openSalesDocumentPage(page, id, options.kind);
494
+ return id;
495
+ }
496
+ await expect(page.getByRole('button', { name: /^Create$/i }).first()).toBeVisible({
497
+ timeout: TEST_WAIT_TIMEOUT_MS,
498
+ });
499
+
500
+ const generateButton = page.getByRole('button', { name: /Generate/i }).first();
501
+ const createButton = page.getByRole('button', { name: /^Create$/i }).first();
502
+ const hasGenerateButton = (await generateButton.count()) > 0;
503
+ if (hasGenerateButton) {
504
+ await expect(generateButton).toBeVisible({ timeout: 10_000 });
505
+ await expect(generateButton).toBeEnabled({ timeout: 30_000 });
506
+ }
507
+
508
+ await page.getByText('Document type').click();
509
+ const customerSelected = await selectLookupValue(
510
+ page.getByRole('textbox', { name: /Search customers/i }).first(),
511
+ customerQuery,
512
+ new RegExp(escapeRegExp(customerQuery), 'i'),
513
+ );
514
+ if (!customerSelected) throw new Error(`Could not select customer "${customerQuery}" while creating sales ${options.kind}.`);
515
+
516
+ await selectLookupValue(
517
+ page.getByRole('textbox', { name: /Select a channel/i }).first(),
518
+ channelQuery,
519
+ new RegExp(escapeRegExp(channelQuery), 'i'),
520
+ );
521
+
522
+ await selectFirstAddressIfAvailable(page);
523
+
524
+ const createEnabled = await createButton.isEnabled().catch(() => false);
525
+ if (!createEnabled) {
526
+ const token = await getAuthToken(page.request, 'admin');
527
+ const id = await createSalesDocumentFixture(page, token, options.kind, customerQuery, channelQuery);
528
+ await openSalesDocumentPage(page, id, options.kind);
529
+ return id;
530
+ }
531
+
532
+ await createButton.click();
533
+ const navigated = await page.waitForURL(
534
+ new RegExp(
535
+ `/backend/sales/(?:documents/[0-9a-f-]{36}\\?kind=${options.kind}|${options.kind === 'order' ? 'orders' : 'quotes'}/[0-9a-f-]{36})$`,
536
+ 'i',
537
+ ),
538
+ { timeout: TEST_WAIT_TIMEOUT_MS },
539
+ ).then(() => true).catch(() => false);
540
+ if (!navigated) {
541
+ const token = await getAuthToken(page.request, 'admin');
542
+ const id = await createSalesDocumentFixture(page, token, options.kind, customerQuery, channelQuery);
543
+ await openSalesDocumentPage(page, id, options.kind);
544
+ return id;
545
+ }
546
+
547
+ const match = page.url().match(/\/backend\/sales\/(?:documents|orders|quotes)\/([0-9a-f-]{36})/i);
548
+ if (!match) {
549
+ throw new Error(`Could not resolve document id from URL: ${page.url()}`);
550
+ }
551
+ const loaded = await waitForDocumentLoaded(page, TEST_WAIT_TIMEOUT_MS);
552
+ if (!loaded) {
553
+ await openSalesDocumentPage(page, match[1], options.kind);
554
+ }
555
+ return match[1];
556
+ }
557
+
558
+ function lineDialog(page: Page): Locator {
559
+ return page.getByRole('dialog', { name: /Add line|Edit line/i });
560
+ }
561
+
562
+ async function selectFirstOption(container: Locator, rowNamePattern: RegExp): Promise<void> {
563
+ const optionRow = container.getByRole('button', { name: rowNamePattern }).first();
564
+ await optionRow.waitFor({ state: 'visible', timeout: 4_000 }).catch(() => {});
565
+ if ((await optionRow.count()) === 0) return;
566
+
567
+ const selectButton = optionRow.getByRole('button', { name: /^Select$/i }).first();
568
+ if ((await selectButton.count()) > 0) {
569
+ await selectButton.click();
570
+ return;
571
+ }
572
+ await optionRow.click();
573
+ }
574
+
575
+ async function selectFirstLookupOption(input: Locator, rowNamePattern: RegExp): Promise<void> {
576
+ const root = lookupRootFromInput(input);
577
+ await waitForLookupIdle(root);
578
+ await selectFirstOption(root, rowNamePattern);
579
+ }
580
+
581
+ async function selectShipmentMethod(dialog: Locator): Promise<void> {
582
+ const shippingMethodInput = dialog.getByPlaceholder(/Select method/i).first();
583
+ if ((await shippingMethodInput.count()) === 0) return;
584
+ const selected = await selectLookupValue(shippingMethodInput, 'Standard', /standard ground|express air|standard/i);
585
+ if (!selected) {
586
+ await selectFirstLookupOption(shippingMethodInput, /standard ground|express air|standard/i);
587
+ }
588
+ }
589
+
590
+ async function selectShipmentStatus(dialog: Locator): Promise<void> {
591
+ const statusInput = dialog.getByPlaceholder(/Select shipment status/i).first();
592
+ if ((await statusInput.count()) > 0) {
593
+ const selected = await selectLookupValue(statusInput, 'Shipped', /shipped|in transit|packed/i);
594
+ if (selected) return;
595
+ await selectFirstLookupOption(statusInput, /shipped|in transit|packed/i);
596
+ return;
597
+ }
598
+ }
599
+
600
+ async function selectShipmentAddress(dialog: Locator): Promise<void> {
601
+ const addressInput = dialog.getByPlaceholder(/Select address/i).first();
602
+ if ((await addressInput.count()) === 0) return;
603
+ const currentValue = await addressInput.inputValue().catch(() => '');
604
+ if (currentValue.trim().length > 0) return;
605
+ const selected = await selectLookupValue(addressInput, 'Address', /shipping address|document address|address/i);
606
+ if (!selected) {
607
+ await selectFirstLookupOption(addressInput, /shipping address|document address|address/i);
608
+ }
609
+ }
610
+
611
+ async function fillShipmentQuantity(dialog: Locator): Promise<void> {
612
+ const quantityInputs = dialog.getByRole('spinbutton');
613
+ const count = await quantityInputs.count();
614
+ for (let index = 0; index < count; index += 1) {
615
+ const input = quantityInputs.nth(index);
616
+ const isVisible = await input.isVisible().catch(() => false);
617
+ const isEnabled = await input.isEnabled().catch(() => false);
618
+ if (!isVisible || !isEnabled) continue;
619
+ await input.fill('1').catch(() => {});
620
+ }
621
+ }
622
+
623
+ async function fillShipmentDates(dialog: Locator): Promise<void> {
624
+ const now = new Date();
625
+ const day = String(now.getDate()).padStart(2, '0');
626
+ const month = String(now.getMonth() + 1).padStart(2, '0');
627
+ const year = String(now.getFullYear());
628
+ const shippedDateValue = `${day}/${month}/${year}`;
629
+
630
+ const shippedDateInput = dialog.getByLabel(/Shipped date/i).first();
631
+ if ((await shippedDateInput.count()) > 0) {
632
+ const currentValue = await shippedDateInput.inputValue().catch(() => '');
633
+ if (currentValue.trim().length === 0) {
634
+ await shippedDateInput.fill(shippedDateValue).catch(() => {});
635
+ }
636
+ }
637
+ }
638
+
639
+ async function fillShipmentNumber(dialog: Locator, shipmentNumber: string): Promise<void> {
640
+ const shipmentNumberInput = dialog.getByRole('textbox').first();
641
+ if (!(await shipmentNumberInput.isVisible().catch(() => false)) && (await shipmentNumberInput.count().catch(() => 0)) === 0) {
642
+ return;
643
+ }
644
+ await waitForStableVisibility(shipmentNumberInput, 4_000).catch(() => {});
645
+ await shipmentNumberInput.fill(shipmentNumber).catch(() => {});
646
+ await shipmentNumberInput.press('Tab').catch(() => {});
647
+ }
648
+
649
+ export async function addCustomLine(page: Page, options: AddLineOptions): Promise<void> {
650
+ await ensureSalesDocumentReady(page);
651
+ await page.getByRole('button', { name: /^Items$/i }).click();
652
+ const addItemButton = page.getByRole('button', { name: /Add item/i }).first();
653
+ await expect(addItemButton).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
654
+ await expect(addItemButton).toBeEnabled({ timeout: TEST_WAIT_TIMEOUT_MS });
655
+ await addItemButton.click();
656
+
657
+ const dialog = lineDialog(page);
658
+ await expect(dialog).toBeVisible();
659
+
660
+ const customLineButton = dialog.getByRole('button', { name: /Custom line/i });
661
+ await expect(customLineButton).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
662
+ await customLineButton.click();
663
+
664
+ const nameInput = dialog.getByRole('textbox', { name: /Optional line name/i });
665
+ await expect(nameInput).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
666
+ await nameInput.fill(options.name);
667
+ await dialog.getByRole('textbox', { name: '0.00' }).fill(String(options.unitPriceGross));
668
+ await dialog.getByRole('textbox', { name: '1' }).fill(String(options.quantity));
669
+
670
+ if (options.taxClassName) {
671
+ const taxClassSelect = dialog
672
+ .locator('select')
673
+ .filter({ has: dialog.locator('option', { hasText: /No tax class selected/i }) })
674
+ .first();
675
+ if ((await taxClassSelect.count()) > 0) {
676
+ await taxClassSelect.selectOption({ label: options.taxClassName });
677
+ }
678
+ }
679
+
680
+ const submitButton = dialog.getByRole('button', { name: /Add item/i });
681
+ await expect(submitButton).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
682
+ await expect(submitButton).toBeEnabled({ timeout: TEST_WAIT_TIMEOUT_MS });
683
+ const lineRow = page.getByRole('row', { name: new RegExp(escapeRegExp(options.name), 'i') });
684
+ for (let attempt = 0; attempt < 2; attempt += 1) {
685
+ await submitButton.click();
686
+ await Promise.race([
687
+ dialog.waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {}),
688
+ lineRow.waitFor({ state: 'visible', timeout: 3_000 }).catch(() => {}),
689
+ ]);
690
+ if (await lineRow.isVisible().catch(() => false)) break;
691
+ if (!(await dialog.isVisible().catch(() => false))) break;
692
+ }
693
+
694
+ if (await dialog.isVisible().catch(() => false)) {
695
+ await expect(dialog).toBeHidden({ timeout: TEST_WAIT_TIMEOUT_MS });
696
+ }
697
+ await page.getByRole('button', { name: /^Items$/i }).click().catch(() => {});
698
+ await expect(lineRow).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
699
+ }
700
+
701
+ export async function updateLineQuantity(page: Page, lineName: string, quantity: number): Promise<void> {
702
+ await page.getByRole('button', { name: /^Items$/i }).click();
703
+ const row = page.getByRole('row', { name: new RegExp(escapeRegExp(lineName), 'i') });
704
+ await row.click();
705
+
706
+ const dialog = page.getByRole('dialog', { name: /Edit line/i });
707
+ await expect(dialog).toBeVisible();
708
+ await dialog.getByRole('textbox', { name: '1' }).fill(String(quantity));
709
+ await dialog.getByRole('button', { name: /Save changes/i }).click();
710
+
711
+ await expect(page.getByRole('row', { name: new RegExp(`${escapeRegExp(lineName)}.*\\b${quantity}\\b`, 'i') })).toBeVisible();
712
+ }
713
+
714
+ export async function deleteLine(page: Page, lineName: string): Promise<void> {
715
+ await page.getByRole('button', { name: /^Items$/i }).click();
716
+ const row = page.getByRole('row', { name: new RegExp(escapeRegExp(lineName), 'i') });
717
+ await expect(row).toBeVisible();
718
+ await row.locator('button').last().click();
719
+
720
+ const confirmDialog = page.getByRole('alertdialog');
721
+ if (await confirmDialog.isVisible().catch(() => false)) {
722
+ await confirmDialog.getByRole('button', { name: /^Delete$/i }).first().click();
723
+ await expect(confirmDialog).toBeHidden();
724
+ }
725
+
726
+ await expect(page.getByRole('row', { name: new RegExp(escapeRegExp(lineName), 'i') })).toHaveCount(0);
727
+ }
728
+
729
+ export async function addAdjustment(page: Page, options: AddAdjustmentOptions): Promise<void> {
730
+ const adjustmentsTab = page.getByRole('button', { name: /^Adjustments$/i }).first();
731
+ await waitForStableVisibility(adjustmentsTab, TEST_WAIT_TIMEOUT_MS);
732
+ await adjustmentsTab.click();
733
+ const addAdjustmentButton = page.getByRole('button', { name: /Add adjustment/i }).first();
734
+ await waitForStableVisibility(addAdjustmentButton, TEST_WAIT_TIMEOUT_MS);
735
+ await addAdjustmentButton.click();
736
+
737
+ const dialog = page.getByRole('dialog', { name: /Add adjustment/i });
738
+ await expect(dialog).toBeVisible();
739
+ const adjustmentRow = page.getByRole('row', { name: new RegExp(escapeRegExp(options.label), 'i') });
740
+ const fillAdjustmentForm = async (): Promise<void> => {
741
+ await dialog.getByText(/Loading adjustments/i).waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {});
742
+ const kindSelect = dialog.locator('select').first();
743
+ await expect(kindSelect).toHaveValue(/^custom$/i, { timeout: 3_000 });
744
+
745
+ const labelInput = dialog.getByPlaceholder(/e\.g\. Shipping fee/i).first();
746
+ await expect(labelInput).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
747
+ await labelInput.fill(options.label);
748
+ await expect(labelInput).toHaveValue(options.label, { timeout: 2_000 });
749
+
750
+ if ((await kindSelect.count()) > 0) {
751
+ const expectedKindValue = normalizeAdjustmentKindValue(options.kindLabel ?? 'Surcharge');
752
+ await kindSelect.locator('option', { hasText: new RegExp(`^${escapeRegExp(options.kindLabel ?? 'Surcharge')}$`, 'i') })
753
+ .first()
754
+ .waitFor({ state: 'attached', timeout: 2_000 })
755
+ .catch(() => {});
756
+ await kindSelect.selectOption({ label: options.kindLabel ?? 'Surcharge' }).catch(async () => {
757
+ await kindSelect.selectOption({ label: 'Custom' });
758
+ });
759
+ await expect(kindSelect).toHaveValue(new RegExp(`^${escapeRegExp(expectedKindValue)}$`, 'i'), {
760
+ timeout: 2_000,
761
+ });
762
+ }
763
+
764
+ const fixedAmountButton = dialog.getByRole('button', { name: /^Fixed amount$/i }).first();
765
+ if ((await fixedAmountButton.count()) > 0) {
766
+ await fixedAmountButton.click().catch(() => {});
767
+ }
768
+
769
+ const enabledAmountInputs = dialog.locator('input[placeholder="0.00"]:not([disabled])');
770
+ await expect(enabledAmountInputs.first()).toBeVisible({ timeout: TEST_WAIT_TIMEOUT_MS });
771
+ if ((await enabledAmountInputs.count()) > 0) {
772
+ await enabledAmountInputs.first().fill(String(options.netAmount));
773
+ await expect(enabledAmountInputs.first()).toHaveValue(String(options.netAmount), { timeout: 2_000 }).catch(() => {});
774
+ }
775
+ };
776
+
777
+ let saved = false;
778
+ for (let attempt = 0; attempt < 3; attempt += 1) {
779
+ await fillAdjustmentForm();
780
+ const submitButton = dialog.getByRole('button', { name: /Add adjustment/i }).first();
781
+ await waitForStableVisibility(submitButton, 4_000).catch(() => {});
782
+ await submitButton.click();
783
+ await Promise.race([
784
+ dialog.waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {}),
785
+ adjustmentRow.waitFor({ state: 'visible', timeout: 3_000 }).catch(() => {}),
786
+ ]);
787
+ saved =
788
+ !(await dialog.isVisible().catch(() => false)) ||
789
+ (await adjustmentRow.isVisible().catch(() => false));
790
+ if (saved) break;
791
+ }
792
+
793
+ if (await dialog.isVisible().catch(() => false)) {
794
+ await expect(dialog).toBeHidden({ timeout: 5_000 });
795
+ }
796
+ if (!(await adjustmentRow.isVisible().catch(() => false))) {
797
+ await adjustmentsTab.click().catch(() => {});
798
+ await adjustmentRow.waitFor({ state: 'visible', timeout: 2_000 }).catch(() => {});
799
+ }
800
+ }
801
+
802
+ export async function addPayment(page: Page, amount: number): Promise<{ amountLabel: string; added: boolean }> {
803
+ await ensureSalesDocumentReady(page);
804
+ const paymentsTab = page.getByRole('button', { name: /^Payments$/i }).first();
805
+ await waitForStableVisibility(paymentsTab, TEST_WAIT_TIMEOUT_MS);
806
+ await paymentsTab.click();
807
+ const amountLabel = amount.toFixed(2);
808
+ const amountInputValue = String(Math.max(1, Math.round(amount)));
809
+ const addPaymentButton = page.getByRole('button', { name: /Add payment/i }).first();
810
+ await waitForStableVisibility(addPaymentButton, TEST_WAIT_TIMEOUT_MS);
811
+ await expect(addPaymentButton).toBeEnabled({ timeout: TEST_WAIT_TIMEOUT_MS });
812
+ await addPaymentButton.click();
813
+
814
+ const dialog = page.getByRole('dialog', { name: /Add payment/i });
815
+ const amountInput = dialog.locator('input[placeholder="0.00"]').first();
816
+ await waitForDialogFieldReady(dialog, amountInput, /Loading payment methods…|Loading payment methods\.\.\./i);
817
+ const setAmount = async (): Promise<void> => {
818
+ const refreshedAmountInput = dialog.locator('input[placeholder="0.00"]').first();
819
+ await waitForStableVisibility(refreshedAmountInput, 4_000).catch(() => {});
820
+ await refreshedAmountInput.fill(amountInputValue).catch(() => {});
821
+ await refreshedAmountInput.press('Tab').catch(() => {});
822
+ };
823
+ const selectMethodInput = dialog.getByPlaceholder(/Search payment method/i).first();
824
+ const statusInput = dialog.getByPlaceholder(/Select status/i).first();
825
+ await setAmount();
826
+ await waitForStableVisibility(selectMethodInput, 4_000).catch(() => {});
827
+ await selectLookupValue(selectMethodInput, 'Bank', /bank transfer|credit card|cash on delivery/i).catch(() => false);
828
+ await waitForStableVisibility(statusInput, 4_000).catch(() => {});
829
+ await selectLookupValue(statusInput, 'Pending', /pending|captured/i).catch(() => false);
830
+ const saveButton = dialog.getByRole('button', { name: /Save/i }).first();
831
+ const operationMessage = page.getByText(/Last operation:\s*Create payment/i).first();
832
+ for (let attempt = 0; attempt < 2; attempt += 1) {
833
+ await setAmount();
834
+ await selectLookupValue(selectMethodInput, 'Bank', /bank transfer|credit card|cash on delivery/i).catch(() => false);
835
+ await selectLookupValue(statusInput, 'Pending', /pending|captured/i).catch(() => false);
836
+ await waitForStableVisibility(saveButton, 4_000).catch(() => {});
837
+ await saveButton.click();
838
+ await Promise.race([
839
+ dialog.waitFor({ state: 'hidden', timeout: 3_500 }).catch(() => {}),
840
+ operationMessage.waitFor({ state: 'visible', timeout: 3_500 }).catch(() => {}),
841
+ dialog.getByText(/This field is required/i).first().waitFor({ state: 'visible', timeout: 3_500 }).catch(() => {}),
842
+ ]);
843
+ if (!(await dialog.isVisible().catch(() => false))) break;
844
+ if (await operationMessage.isVisible().catch(() => false)) break;
845
+ await waitForOptionalTextToDisappear(dialog, /Loading payment methods…|Loading payment methods\.\.\./i, 3_000);
846
+ }
847
+ if (await dialog.isVisible().catch(() => false)) {
848
+ await dialog.press('Escape').catch(() => {});
849
+ await dialog.waitFor({ state: 'hidden', timeout: 1_500 }).catch(() => {});
850
+ }
851
+ await operationMessage.waitFor({ state: 'visible', timeout: 2_500 }).catch(() => {});
852
+ const added = await operationMessage.isVisible().catch(() => false);
853
+ return { amountLabel, added };
854
+ }
855
+
856
+ export async function addShipment(page: Page): Promise<{ trackingNumber: string; shipmentNumber: string; added: boolean }> {
857
+ await ensureSalesDocumentReady(page);
858
+ await ensureShippingMethodFixture(page);
859
+ const shipmentsTab = page.getByRole('button', { name: /^Shipments$/i }).first();
860
+ await waitForStableVisibility(shipmentsTab, TEST_WAIT_TIMEOUT_MS);
861
+ await shipmentsTab.click();
862
+ const trackingNumber = `SHIP-${Date.now()}`;
863
+ const shipmentNumber = String(Date.now());
864
+ const addShipmentButton = page.getByRole('button', { name: /Add shipment/i }).first();
865
+ await waitForStableVisibility(addShipmentButton, TEST_WAIT_TIMEOUT_MS);
866
+ await expect(addShipmentButton).toBeEnabled({ timeout: TEST_WAIT_TIMEOUT_MS });
867
+ await addShipmentButton.click();
868
+
869
+ const dialog = page.getByRole('dialog', { name: /Add shipment/i });
870
+ const shipmentNumberInput = dialog.getByRole('textbox').first();
871
+ await waitForDialogFieldReady(dialog, shipmentNumberInput, /Loading shipments…|Loading shipments\.\.\./i);
872
+ await fillShipmentNumber(dialog, shipmentNumber);
873
+ const trackingInput = dialog.getByPlaceholder(/One per line or comma separated/i).first();
874
+ await waitForStableVisibility(trackingInput, 4_000).catch(() => {});
875
+ await trackingInput.fill(trackingNumber).catch(() => {});
876
+ await selectShipmentMethod(dialog);
877
+ await selectShipmentStatus(dialog);
878
+ await selectShipmentAddress(dialog);
879
+ await fillShipmentQuantity(dialog);
880
+ await fillShipmentDates(dialog);
881
+ await fillShipmentNumber(dialog, shipmentNumber);
882
+
883
+ await dialog.getByText(/Searching…|Searching\.\.\./i).first().waitFor({ state: 'hidden', timeout: TEST_WAIT_TIMEOUT_MS }).catch(() => {});
884
+ const saveButton = dialog.getByRole('button', { name: /^Save\b/i }).first();
885
+ const canClickSave = (await saveButton.count()) > 0 && (await saveButton.isVisible().catch(() => false));
886
+ if (canClickSave) {
887
+ await saveButton.click({ timeout: TEST_WAIT_TIMEOUT_MS }).catch(() => {});
888
+ } else {
889
+ await dialog.press('ControlOrMeta+Enter').catch(() => {});
890
+ }
891
+
892
+ let closed = await dialog
893
+ .waitFor({ state: 'hidden', timeout: TEST_WAIT_TIMEOUT_MS })
894
+ .then(() => true)
895
+ .catch(() => false);
896
+
897
+ if (!closed) {
898
+ for (let attempt = 0; attempt < 2; attempt += 1) {
899
+ if (!(await dialog.isVisible().catch(() => false))) break;
900
+ await selectShipmentMethod(dialog);
901
+ await selectShipmentAddress(dialog);
902
+ await fillShipmentQuantity(dialog);
903
+ await fillShipmentDates(dialog);
904
+ await fillShipmentNumber(dialog, shipmentNumber);
905
+ if (canClickSave) {
906
+ await saveButton.click({ timeout: 2_000 }).catch(() => {});
907
+ } else {
908
+ await dialog.press('ControlOrMeta+Enter').catch(() => {});
909
+ }
910
+ closed = await dialog
911
+ .waitFor({ state: 'hidden', timeout: 2_000 })
912
+ .then(() => true)
913
+ .catch(() => false);
914
+ if (closed) break;
915
+ }
916
+ }
917
+
918
+ if (!closed) {
919
+ return { trackingNumber, shipmentNumber, added: false };
920
+ }
921
+
922
+ await page.getByRole('button', { name: /^Shipments$/i }).click();
923
+ const shipmentLabel = page.getByText(new RegExp(`Shipment\\s+${escapeRegExp(shipmentNumber)}`, 'i')).first();
924
+ const added = await shipmentLabel
925
+ .waitFor({ state: 'visible', timeout: TEST_WAIT_TIMEOUT_MS })
926
+ .then(() => true)
927
+ .catch(() => false);
928
+ return { trackingNumber, shipmentNumber, added };
929
+ }
930
+
931
+ export async function readGrandTotalGross(page: Page): Promise<number> {
932
+ const row = page.getByRole('row', { name: /Grand total \(gross\)/i }).first();
933
+ await expect(row).toBeVisible();
934
+ const text = (await row.innerText()).trim();
935
+ return parseCurrencyAmount(text);
936
+ }