@letsrunit/playwright 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 (87) hide show
  1. package/README.md +44 -0
  2. package/dist/index.d.ts +106 -0
  3. package/dist/index.js +3006 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +67 -0
  6. package/src/browser.ts +20 -0
  7. package/src/field/calendar.ts +300 -0
  8. package/src/field/date-group.ts +253 -0
  9. package/src/field/date-text-input.ts +270 -0
  10. package/src/field/index.ts +57 -0
  11. package/src/field/native-checkbox.ts +21 -0
  12. package/src/field/native-date.ts +62 -0
  13. package/src/field/native-input.ts +17 -0
  14. package/src/field/native-select.ts +75 -0
  15. package/src/field/otp.ts +22 -0
  16. package/src/field/radio-group.ts +27 -0
  17. package/src/field/slider.ts +132 -0
  18. package/src/field/types.ts +16 -0
  19. package/src/format-html.ts +17 -0
  20. package/src/index.ts +12 -0
  21. package/src/locator.ts +102 -0
  22. package/src/page-info.ts +33 -0
  23. package/src/screenshot.ts +84 -0
  24. package/src/scroll.ts +10 -0
  25. package/src/scrub-html.ts +333 -0
  26. package/src/selector/date-selector.ts +272 -0
  27. package/src/selector/field-selector.ts +121 -0
  28. package/src/selector/index.ts +2 -0
  29. package/src/snapshot.ts +55 -0
  30. package/src/suppress-interferences.ts +288 -0
  31. package/src/translations/af.ts +41 -0
  32. package/src/translations/ar.ts +7 -0
  33. package/src/translations/az.ts +40 -0
  34. package/src/translations/bg.ts +7 -0
  35. package/src/translations/bn.ts +40 -0
  36. package/src/translations/bs.ts +7 -0
  37. package/src/translations/ca.ts +41 -0
  38. package/src/translations/cs.ts +7 -0
  39. package/src/translations/da.ts +44 -0
  40. package/src/translations/de.ts +47 -0
  41. package/src/translations/el.ts +40 -0
  42. package/src/translations/en.ts +7 -0
  43. package/src/translations/es.ts +7 -0
  44. package/src/translations/et.ts +7 -0
  45. package/src/translations/eu.ts +7 -0
  46. package/src/translations/fa.ts +7 -0
  47. package/src/translations/fi.ts +39 -0
  48. package/src/translations/fr.ts +42 -0
  49. package/src/translations/ga.ts +40 -0
  50. package/src/translations/he.ts +45 -0
  51. package/src/translations/hi.ts +39 -0
  52. package/src/translations/hr.ts +7 -0
  53. package/src/translations/hu.ts +7 -0
  54. package/src/translations/hy.ts +7 -0
  55. package/src/translations/id.ts +7 -0
  56. package/src/translations/index.ts +68 -0
  57. package/src/translations/is.ts +7 -0
  58. package/src/translations/it.ts +7 -0
  59. package/src/translations/ja.ts +7 -0
  60. package/src/translations/ka.ts +36 -0
  61. package/src/translations/ko.ts +7 -0
  62. package/src/translations/lt.ts +7 -0
  63. package/src/translations/lv.ts +43 -0
  64. package/src/translations/nl.ts +43 -0
  65. package/src/translations/no.ts +46 -0
  66. package/src/translations/pl.ts +39 -0
  67. package/src/translations/pt.ts +41 -0
  68. package/src/translations/ro.ts +40 -0
  69. package/src/translations/ru.ts +7 -0
  70. package/src/translations/sk.ts +7 -0
  71. package/src/translations/sl.ts +7 -0
  72. package/src/translations/sv.ts +44 -0
  73. package/src/translations/sw.ts +7 -0
  74. package/src/translations/ta.ts +7 -0
  75. package/src/translations/th.ts +39 -0
  76. package/src/translations/tl.ts +7 -0
  77. package/src/translations/tr.ts +41 -0
  78. package/src/translations/uk.ts +7 -0
  79. package/src/translations/ur.ts +43 -0
  80. package/src/translations/vi.ts +7 -0
  81. package/src/translations/zh.ts +7 -0
  82. package/src/types.ts +37 -0
  83. package/src/unified-html-diff.ts +22 -0
  84. package/src/utils/date.ts +40 -0
  85. package/src/utils/pick-field-element.ts +48 -0
  86. package/src/utils/type-check.ts +7 -0
  87. package/src/wait.ts +170 -0
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["Sprejmi", "Sprejmi vse", "Strinjam se", "Dovoli", "Dovoli vse", "V redu", "OK", "Razumem", "Nadaljuj", "Shrani in sprejmi", "Potrdi", "Shrani nastavitve"],
3
+ reject: ["Zavrni", "Zavrni vse", "Ne sprejemam", "Ne dovoli", "Samo nujno", "Le nujni piškotki", "Nadaljuj brez piškotkov", "Ne, hvala", "Onemogoči vse", "Zavrni neobvezne"],
4
+ close: ["Zapri", "Prekliči", "Ne zdaj", "Kasneje", "×", "Skrij", "Zapri obvestilo", "Nazaj", "Zapri okno"]
5
+ };
6
+
7
+ export default translations;
@@ -0,0 +1,44 @@
1
+ const translations = {
2
+ accept: [
3
+ "Godkänn",
4
+ "Godkänn alla",
5
+ "Acceptera",
6
+ "Acceptera alla",
7
+ "Jag samtycker",
8
+ "Tillåt",
9
+ "Tillåt alla",
10
+ "OK",
11
+ "Okej",
12
+ "Jag förstår",
13
+ "Fortsätt",
14
+ "Spara och godkänn",
15
+ "Bekräfta",
16
+ "Gå vidare",
17
+ "Klart"
18
+ ],
19
+ reject: [
20
+ "Avvisa",
21
+ "Avvisa alla",
22
+ "Avböj",
23
+ "Neka",
24
+ "Neka alla",
25
+ "Endast nödvändiga",
26
+ "Enbart nödvändiga",
27
+ "Fortsätt utan kakor",
28
+ "Nej tack",
29
+ "Avstå"
30
+ ],
31
+ close: [
32
+ "Stäng",
33
+ "Avbryt",
34
+ "Stäng fönster",
35
+ "Inte nu",
36
+ "Nej tack",
37
+ "Senare",
38
+ "Hoppa över",
39
+ "Tillbaka",
40
+ "×"
41
+ ]
42
+ };
43
+
44
+ export default translations;
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["Kubali", "Kubali yote", "Ruhusu", "Ruhusu yote", "Sawa", "Sawa sawa", "Nimeelewa", "Endelea", "Hifadhi na kubali", "Nakubali"],
3
+ reject: ["Kataa", "Kataa yote", "Usiruhusu", "Usikubali", "Muhimu tu", "Ya lazima pekee", "Endelea bila vidakuzi", "Hapana, asante"],
4
+ close: ["Funga", "Ghairi", "Ondoa", "Puuza", "Tupilia mbali", "Si sasa", "Baadaye", "×"]
5
+ };
6
+
7
+ export default translations;
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["ஏற்கவும்", "அனைத்தையும் ஏற்கவும்", "உடன்படுகிறேன்", "அனுமதிக்கவும்", "அனைத்தையும் அனுமதிக்கவும்", "சரி", "ஓகே", "புரிந்தது", "தொடரவும்", "சேமித்து ஏற்கவும்"],
3
+ reject: ["நிராகரிக்கவும்", "அனைத்தையும் நிராகரிக்கவும்", "மறுக்கவும்", "அனுமதிக்க வேண்டாம்", "தேவையானவை மட்டும்", "முக்கியமானவை மட்டும்", "குக்கீகள் இல்லாமல் தொடரவும்", "இல்லை, நன்றி"],
4
+ close: ["மூடவும்", "ரத்து செய்யவும்", "தவிர்க்கவும்", "இப்போது வேண்டாம்", "இல்லை, நன்றி", "பின்னர்", "×"]
5
+ };
6
+
7
+ export default translations;
@@ -0,0 +1,39 @@
1
+ const translations = {
2
+ accept: [
3
+ "ยอมรับ",
4
+ "ยอมรับทั้งหมด",
5
+ "เห็นด้วย",
6
+ "อนุญาต",
7
+ "อนุญาตทั้งหมด",
8
+ "ตกลง",
9
+ "โอเค",
10
+ "รับทราบ",
11
+ "ดำเนินการต่อ",
12
+ "บันทึกและยอมรับ",
13
+ "บันทึกการตั้งค่าและยอมรับ",
14
+ "ยืนยัน"
15
+ ],
16
+ reject: [
17
+ "ปฏิเสธ",
18
+ "ปฏิเสธทั้งหมด",
19
+ "ไม่ยอมรับ",
20
+ "ไม่อนุญาต",
21
+ "เฉพาะคุกกี้ที่จำเป็น",
22
+ "ใช้เฉพาะที่จำเป็น",
23
+ "ไปต่อโดยไม่ใช้คุกกี้",
24
+ "ไม่ ขอบคุณ",
25
+ "ข้าม"
26
+ ],
27
+ close: [
28
+ "ปิด",
29
+ "ยกเลิก",
30
+ "ปิดหน้าต่าง",
31
+ "ปิดการแจ้งเตือน",
32
+ "ยังไม่ใช่ตอนนี้",
33
+ "ไว้ทีหลัง",
34
+ "ทีหลัง",
35
+ "×"
36
+ ]
37
+ };
38
+
39
+ export default translations;
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["Tanggapin", "Tanggapin lahat", "Sumang-ayon", "Payagan", "Payagan lahat", "OK", "Okay", "Sige", "Naintindihan", "Magpatuloy", "I-save at tanggapin"],
3
+ reject: ["Tanggihan", "Tanggihan lahat", "Huwag payagan", "Kinakailangan lang", "Mga kinakailangan lang", "Mahahalaga lang", "Magpatuloy nang walang mga cookie", "Hindi, salamat", "Huwag na lang"],
4
+ close: ["Isara", "Kanselahin", "Isantabi", "Hindi ngayon", "Huwag muna", "Mamaya", "×"]
5
+ };
6
+
7
+ export default translations;
@@ -0,0 +1,41 @@
1
+ const translations = {
2
+ accept: [
3
+ "Kabul et",
4
+ "Tümünü kabul et",
5
+ "Onayla",
6
+ "İzin ver",
7
+ "Tümüne izin ver",
8
+ "Anladım",
9
+ "Devam et",
10
+ "Kaydet ve kabul et",
11
+ "Ayarları kaydet",
12
+ "Kabul ediyorum"
13
+ ],
14
+ reject: [
15
+ "Reddet",
16
+ "Tümünü reddet",
17
+ "Kabul etmiyorum",
18
+ "İzin verme",
19
+ "Yalnızca gerekli olanlar",
20
+ "Sadece zorunlu olanlar",
21
+ "Gerekli olanlarla devam et",
22
+ "Çerez olmadan devam et",
23
+ "Çerezsiz devam et",
24
+ "Hayır, teşekkürler",
25
+ "Yalnızca zorunlu çerezlere izin ver"
26
+ ],
27
+ close: [
28
+ "Kapat",
29
+ "İptal",
30
+ "Vazgeç",
31
+ "Şimdilik değil",
32
+ "Daha sonra",
33
+ "Geri",
34
+ "Pencereyi kapat",
35
+ "×",
36
+ "X",
37
+ "Tamam"
38
+ ]
39
+ };
40
+
41
+ export default translations;
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["Прийняти", "Прийняти все", "Погодитися", "Дозволити", "Дозволити все", "ОК", "Добре", "Зрозуміло", "Продовжити", "Зберегти та прийняти", "Підтвердити", "Так", "Гаразд", "Далі"],
3
+ reject: ["Відхилити", "Відхилити все", "Відмовитися", "Не приймати", "Не дозволяти", "Лише необхідні", "Тільки необхідні", "Лише обов’язкові", "Продовжити без файлів cookie", "Без файлів cookie", "Ні, дякую"],
4
+ close: ["Закрити", "Скасувати", "Закрити вікно", "Не зараз", "Пізніше", "Пропустити", "Готово", "×"]
5
+ };
6
+
7
+ export default translations;
@@ -0,0 +1,43 @@
1
+ const translations = {
2
+ accept: [
3
+ "قبول کریں",
4
+ "سب قبول کریں",
5
+ "قبول ہے",
6
+ "میں متفق ہوں",
7
+ "اجازت دیں",
8
+ "تمام کی اجازت دیں",
9
+ "ٹھیک ہے",
10
+ "اوکے",
11
+ "جاری رکھیں",
12
+ "آگے بڑھیں",
13
+ "محفوظ کریں اور قبول کریں",
14
+ "ہاں"
15
+ ],
16
+ reject: [
17
+ "رد کریں",
18
+ "سب رد کریں",
19
+ "انکار کریں",
20
+ "اجازت نہ دیں",
21
+ "صرف ضروری",
22
+ "صرف لازمی",
23
+ "صرف بنیادی",
24
+ "کوکیز کے بغیر جاری رکھیں",
25
+ "نہیں، شکریہ",
26
+ "قبول نہیں",
27
+ "منع کریں",
28
+ "اختیارات محدود کریں"
29
+ ],
30
+ close: [
31
+ "بند کریں",
32
+ "منسوخ کریں",
33
+ "خارج کریں",
34
+ "ابھی نہیں",
35
+ "بعد میں",
36
+ "نہیں، شکریہ",
37
+ "×",
38
+ "واپس",
39
+ "چھپائیں"
40
+ ]
41
+ };
42
+
43
+ export default translations;
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["Đồng ý", "Tôi đồng ý", "Chấp nhận", "Chấp nhận tất cả", "Cho phép", "Cho phép tất cả", "OK", "Đã hiểu", "Tiếp tục", "Lưu và chấp nhận"],
3
+ reject: ["Từ chối", "Từ chối tất cả", "Không đồng ý", "Không cho phép", "Chỉ bắt buộc", "Chỉ cho phép cần thiết", "Tiếp tục mà không cần cookie", "Không, cảm ơn"],
4
+ close: ["Đóng", "Hủy", "Bỏ qua", "Không phải bây giờ", "Để sau", "Không, cảm ơn", "×", "Tắt", "Ẩn"]
5
+ };
6
+
7
+ export default translations;
@@ -0,0 +1,7 @@
1
+ const translations = {
2
+ accept: ["同意", "全部同意", "接受", "全部接受", "允许", "全部允许", "确定", "好的", "我知道了", "继续", "保存并接受", "继续并同意"],
3
+ reject: ["拒绝", "全部拒绝", "不同意", "不允许", "仅保留必要项", "仅限必要", "仅启用必要功能", "仅使用必要 Cookie", "继续但不使用 Cookie", "不用了,谢谢", "暂不同意"],
4
+ close: ["关闭", "取消", "忽略", "暂不", "稍后", "以后再说", "返回", "×"]
5
+ };
6
+
7
+ export default translations;
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ export interface Snapshot {
2
+ url: string;
3
+ html: string;
4
+ screenshot: File;
5
+ }
6
+
7
+ export interface PageInfo {
8
+ /** Page name (from <title> or meta tags) */
9
+ name?: string;
10
+
11
+ /** Short page description (meta description or OG/Twitter) */
12
+ description?: string;
13
+
14
+ /** Main image for previews (OpenGraph/Twitter) */
15
+ image?: string;
16
+
17
+ /** Site or brand logo if available */
18
+ logo?: string;
19
+
20
+ /** Author name if specified */
21
+ author?: string;
22
+
23
+ /** Publisher or organization name */
24
+ publisher?: string;
25
+
26
+ /** Page language, e.g. "en", "nl-NL" */
27
+ lang?: string;
28
+
29
+ /** Favicon URL */
30
+ favicon?: string;
31
+
32
+ /** Canonical or resolved page URL */
33
+ url: string;
34
+
35
+ /** Screenshot of the page */
36
+ screenshot?: File;
37
+ }
@@ -0,0 +1,22 @@
1
+ import type { Page } from '@playwright/test';
2
+ import * as Diff from 'diff';
3
+ import { formatHtml } from './format-html';
4
+ import { scrubHtml } from './scrub-html';
5
+ import { isPage } from './utils/type-check';
6
+
7
+ async function format(rawHtml: string, url: string) {
8
+ const html = await scrubHtml({ html: rawHtml, url });
9
+ return await formatHtml(html);
10
+ }
11
+
12
+ export async function unifiedHtmlDiff(
13
+ old: { html: string; url: string } | Page,
14
+ current: { html: string; url: string } | Page,
15
+ ): Promise<string> {
16
+ if (isPage(old)) old = { html: await old.content(), url: old.url() };
17
+ if (isPage(current)) current = { html: await current.content(), url: current.url() };
18
+
19
+ const [a, b] = await Promise.all([format(old.html, old.url), format(current.html, current.url)]);
20
+
21
+ return Diff.createTwoFilesPatch('before.html', 'after.html', a, b);
22
+ }
@@ -0,0 +1,40 @@
1
+ import { getWeekNumber } from '@letsrunit/utils';
2
+
3
+ export function formatDateForInput(date: Date, type: string | null): string {
4
+ const pad = (n: number) => String(n).padStart(2, '0');
5
+ const yyyy = date.getFullYear();
6
+ const mm = pad(date.getMonth() + 1);
7
+ const dd = pad(date.getDate());
8
+ const hh = pad(date.getHours());
9
+ const min = pad(date.getMinutes());
10
+
11
+ switch (type) {
12
+ case 'number':
13
+ return date.getTime().toString();
14
+ case 'datetime-local':
15
+ return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
16
+ case 'month':
17
+ return `${yyyy}-${mm}`;
18
+ case 'week': {
19
+ const week = getWeekNumber(date);
20
+ return `${yyyy}-W${pad(week)}`;
21
+ }
22
+ case 'time':
23
+ return `${hh}:${min}`;
24
+ case 'date':
25
+ default:
26
+ return `${yyyy}-${mm}-${dd}`;
27
+ }
28
+ }
29
+
30
+ export function formatDate(d: Date, format: string): string {
31
+ const dd = String(d.getDate()).padStart(2, '0');
32
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
33
+ const yyyy = String(d.getFullYear());
34
+ return format.replace('DD', dd).replace('MM', mm).replace('YYYY', yyyy);
35
+ }
36
+
37
+ export function getMonthNames(locale?: string): string[] {
38
+ const formatter = new Intl.DateTimeFormat(locale, { month: 'long' });
39
+ return Array.from({ length: 12 }, (_, i) => formatter.format(new Date(2000, i, 1)));
40
+ }
@@ -0,0 +1,48 @@
1
+ import type { Locator } from '@playwright/test';
2
+
3
+ export async function pickFieldElement(elements: Locator): Promise<Locator> {
4
+ const count = await elements.count();
5
+ if (count === 1) return elements;
6
+
7
+ const candidates: { el: Locator; tag: string; role: string | null; isVisible: boolean }[] = [];
8
+
9
+ for (let i = 0; i < count; i++) {
10
+ const el = elements.nth(i);
11
+ const [tag, role, isVisible] = await el.evaluate((e) => [
12
+ e.tagName.toLowerCase(),
13
+ e.getAttribute('role')?.toLowerCase() || null,
14
+ e.getAttribute('type') !== 'hidden' && e.getAttribute('aria-hidden') !== 'true',
15
+ ]);
16
+ candidates.push({ el, tag: tag as string, role: role as string | null, isVisible: isVisible as boolean });
17
+ }
18
+
19
+ // 1. If there is one of the elements is an input and the input is not hidden or has an aria indicating it is a (form) control, pick that element.
20
+ const formControls = ['input', 'textarea', 'select'];
21
+ const controlRoles = ['checkbox', 'radio', 'textbox', 'combobox', 'listbox', 'slider', 'spinbutton'];
22
+
23
+ const primary = candidates.filter(
24
+ (c) => (formControls.includes(c.tag) || (c.role && controlRoles.includes(c.role))) && c.isVisible,
25
+ );
26
+ if (primary.length === 1) return primary[0].el;
27
+
28
+ // 2. If one element has an aria role that might be logical for a form component (like a `group`), pick that element.
29
+ const groups = candidates.filter((c) => c.role === 'group');
30
+ if (groups.length === 1) return groups[0].el;
31
+
32
+ // 3. If one element contains the other element(s), pick the parent.
33
+ const isParent = await elements.evaluateAll((elems) => {
34
+ const results = elems.map((el, i) => {
35
+ const others = elems.filter((_, j) => i !== j);
36
+ return others.every((other) => el.contains(other));
37
+ });
38
+ const parentIndex = results.indexOf(true);
39
+ return parentIndex !== -1 ? parentIndex : null;
40
+ });
41
+
42
+ if (isParent !== null) {
43
+ return elements.nth(isParent);
44
+ }
45
+
46
+ // Return both elements and let the error (on evaluate) occur.
47
+ return elements;
48
+ }
@@ -0,0 +1,7 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ export function isPage(page: any): page is Page {
4
+ return typeof page.content === 'function'
5
+ && typeof page.url === 'function'
6
+ && typeof page.screenshot === 'function';
7
+ }
package/src/wait.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { sleep } from '@letsrunit/utils';
2
+ import type { Locator, Page } from '@playwright/test';
3
+
4
+ export async function waitForIdle(page: Page, timeout = 2500) {
5
+ await page.waitForLoadState('domcontentloaded');
6
+ try {
7
+ await page.waitForLoadState('networkidle', { timeout });
8
+ } catch {}
9
+ }
10
+
11
+ export async function waitForMeta(page: Page, timeout = 2500) {
12
+ await waitForIdle(page);
13
+
14
+ page.getByRole('navigation');
15
+
16
+ await page
17
+ .waitForFunction(
18
+ () => {
19
+ const head = document.head;
20
+ if (!head) return false;
21
+
22
+ return Boolean(
23
+ document.title.trim() ||
24
+ head.querySelector('meta[property^="og:"]') ||
25
+ head.querySelector('meta[name^="twitter:"]') ||
26
+ head.querySelector('script[type="application/ld+json"]'),
27
+ );
28
+ },
29
+ { timeout },
30
+ )
31
+ .catch(() => {});
32
+ }
33
+
34
+ /** Wait until the DOM hasn't changed for `quiet` (default 500ms). */
35
+ export async function waitForDomIdle(
36
+ page: Page,
37
+ { quiet = 500, timeout = 10_000 }: { quiet?: number; timeout?: number } = {},
38
+ ) {
39
+ await page.waitForFunction(
40
+ (q) =>
41
+ new Promise<boolean>((resolve) => {
42
+ let last = performance.now();
43
+
44
+ const obs = new MutationObserver(() => (last = performance.now()));
45
+ obs.observe(document, {
46
+ subtree: true,
47
+ childList: true,
48
+ attributes: true,
49
+ characterData: true,
50
+ });
51
+
52
+ const tick = () => {
53
+ if (performance.now() - last >= q) {
54
+ obs.disconnect();
55
+ resolve(true);
56
+ return;
57
+ }
58
+ requestAnimationFrame(tick);
59
+ };
60
+ tick();
61
+ }),
62
+ quiet,
63
+ { timeout },
64
+ );
65
+ }
66
+
67
+ // Wait until there are no running Web Animations on the calendar subtree.
68
+ // This helps with libraries that slide months using WAAPI (common in React UI libs).
69
+ export async function waitForAnimationsToFinish(root: Locator) {
70
+ await root.page().waitForFunction(
71
+ (el) => {
72
+ const animations = (el as HTMLElement).getAnimations?.({ subtree: true }) ?? [];
73
+ return animations.every((a) => a.playState !== 'running');
74
+ },
75
+ await root.elementHandle(),
76
+ );
77
+
78
+ await root.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))));
79
+ }
80
+
81
+ export async function waitForUrlChange(page: Page, prevUrl: string, timeout: number) {
82
+ try {
83
+ await page.waitForFunction((u) => location.href !== u, prevUrl, { timeout });
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export async function waitUntilEnabled(page: Page, target: Locator, timeout: number) {
91
+ // Poll for not disabled && aria-disabled != "true"
92
+ await target.waitFor({ state: 'attached', timeout }).catch(() => {});
93
+ const handle = await target.elementHandle().catch(() => null);
94
+ if (!handle) return;
95
+
96
+ await page
97
+ .waitForFunction(
98
+ (el) => {
99
+ if (!el || !(el as Element).isConnected) return true; // detached → treat as settled
100
+ const aria = (el as HTMLElement).getAttribute('aria-disabled');
101
+ const disabled =
102
+ (el as HTMLButtonElement).disabled ||
103
+ aria === 'true' ||
104
+ (el as HTMLElement).getAttribute('disabled') !== null;
105
+ return !disabled;
106
+ },
107
+ handle,
108
+ { timeout },
109
+ )
110
+ .catch(() => {});
111
+ }
112
+
113
+ export async function waitAfterInteraction(
114
+ page: Page,
115
+ target: Locator,
116
+ opts: { prevUrl?: string; navTimeout?: number; settleTimeout?: number; quietMs?: number } = {},
117
+ ) {
118
+ const navTimeout = opts.navTimeout ?? 8_000;
119
+ const settleTimeout = opts.settleTimeout ?? 6_000;
120
+ const quietMs = opts.quietMs ?? 500;
121
+
122
+ const prevUrl = opts.prevUrl ?? page.url();
123
+ const kind = await elementKind(target).catch(() => 'other');
124
+
125
+ if (kind === 'link') {
126
+ // SPA or full nav: wait for URL change; if it changed, wait for DOM to settle.
127
+ const urlChanged = await waitForUrlChange(page, prevUrl, navTimeout);
128
+ if (urlChanged) {
129
+ // Avoid 'networkidle' for SPAs with websockets/long-polling.
130
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
131
+ await waitForDomIdle(page, { quiet: quietMs, timeout: navTimeout }).catch(() => {});
132
+ return;
133
+ }
134
+ // Link didn’t navigate (preventDefault, same hash, etc.) → fall back.
135
+ await waitForDomIdle(page, { quiet: quietMs }).catch(() => {});
136
+ return;
137
+ }
138
+
139
+ if (kind === 'button' && (await target.isDisabled())) {
140
+ // Buttons often disable during in-flight work, or disappear on success.
141
+ await Promise.race([
142
+ waitUntilEnabled(page, target, settleTimeout).catch(() => {}),
143
+ target.waitFor({ state: 'hidden', timeout: settleTimeout }).catch(() => {}),
144
+ target.waitFor({ state: 'detached', timeout: settleTimeout }).catch(() => {}),
145
+ waitForUrlChange(page, prevUrl, settleTimeout).catch(() => {}),
146
+ ]);
147
+ await sleep(1000); // Grace periode for redirect
148
+ await waitForDomIdle(page, { quiet: quietMs }).catch(() => {});
149
+ return;
150
+ }
151
+ }
152
+
153
+ /* ---------- helpers ---------- */
154
+
155
+ async function elementKind(target: Locator): Promise<'link' | 'button' | 'other'> {
156
+ const role = await target.getAttribute('role').catch(() => null);
157
+ if (role === 'link') return 'link';
158
+ if (role === 'button') return 'button';
159
+
160
+ const tag = await target.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
161
+ if (tag === 'a') return 'link';
162
+
163
+ if (tag === 'button') return 'button';
164
+ if (tag === 'input') {
165
+ const type = await target.getAttribute('type').catch(() => null);
166
+ if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
167
+ }
168
+
169
+ return 'other';
170
+ }