@wdio/image-comparison-core 1.1.3 → 1.2.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 (62) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/base.d.ts +2 -2
  3. package/dist/base.d.ts.map +1 -1
  4. package/dist/base.interfaces.d.ts +7 -0
  5. package/dist/base.interfaces.d.ts.map +1 -1
  6. package/dist/base.js +1 -5
  7. package/dist/base.test.js +4 -6
  8. package/dist/clientSideScripts/injectWebviewOverlay.test.js +29 -0
  9. package/dist/clientSideScripts/scrollElementIntoView.d.ts.map +1 -1
  10. package/dist/clientSideScripts/scrollElementIntoView.js +4 -1
  11. package/dist/clientSideScripts/scrollElementIntoView.test.js +4 -2
  12. package/dist/commands/check.interfaces.d.ts +1 -1
  13. package/dist/commands/check.interfaces.d.ts.map +1 -1
  14. package/dist/commands/checkFullPageScreen.d.ts.map +1 -1
  15. package/dist/commands/checkFullPageScreen.js +7 -2
  16. package/dist/commands/checkWebElement.d.ts.map +1 -1
  17. package/dist/commands/checkWebElement.js +7 -2
  18. package/dist/commands/checkWebScreen.d.ts.map +1 -1
  19. package/dist/commands/checkWebScreen.js +10 -3
  20. package/dist/commands/checkWebScreen.test.js +43 -0
  21. package/dist/commands/fullPage.interfaces.d.ts +6 -0
  22. package/dist/commands/fullPage.interfaces.d.ts.map +1 -1
  23. package/dist/commands/save.interfaces.d.ts +3 -1
  24. package/dist/commands/save.interfaces.d.ts.map +1 -1
  25. package/dist/commands/saveElement.d.ts +1 -1
  26. package/dist/commands/saveElement.d.ts.map +1 -1
  27. package/dist/commands/saveElement.js +2 -2
  28. package/dist/commands/saveFullPageScreen.d.ts.map +1 -1
  29. package/dist/commands/saveFullPageScreen.js +23 -3
  30. package/dist/commands/saveWebElement.d.ts +1 -1
  31. package/dist/commands/saveWebElement.d.ts.map +1 -1
  32. package/dist/commands/saveWebElement.js +24 -2
  33. package/dist/commands/saveWebScreen.d.ts +1 -1
  34. package/dist/commands/saveWebScreen.d.ts.map +1 -1
  35. package/dist/commands/saveWebScreen.js +23 -3
  36. package/dist/helpers/afterScreenshot.interfaces.d.ts +2 -0
  37. package/dist/helpers/afterScreenshot.interfaces.d.ts.map +1 -1
  38. package/dist/helpers/options.d.ts.map +1 -1
  39. package/dist/helpers/options.interfaces.d.ts +10 -0
  40. package/dist/helpers/options.interfaces.d.ts.map +1 -1
  41. package/dist/helpers/options.js +1 -0
  42. package/dist/helpers/utils.d.ts +10 -0
  43. package/dist/helpers/utils.d.ts.map +1 -1
  44. package/dist/helpers/utils.interfaces.d.ts +3 -0
  45. package/dist/helpers/utils.interfaces.d.ts.map +1 -1
  46. package/dist/helpers/utils.js +105 -33
  47. package/dist/helpers/utils.test.js +210 -3
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/methods/images.d.ts.map +1 -1
  51. package/dist/methods/images.interfaces.d.ts +4 -0
  52. package/dist/methods/images.interfaces.d.ts.map +1 -1
  53. package/dist/methods/images.js +4 -2
  54. package/dist/methods/rectangles.d.ts +33 -2
  55. package/dist/methods/rectangles.d.ts.map +1 -1
  56. package/dist/methods/rectangles.interfaces.d.ts +58 -0
  57. package/dist/methods/rectangles.interfaces.d.ts.map +1 -1
  58. package/dist/methods/rectangles.js +289 -15
  59. package/dist/methods/rectangles.test.js +558 -2
  60. package/dist/methods/takeElementScreenshots.js +19 -16
  61. package/dist/methods/takeElementScreenshots.test.js +22 -22
  62. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { join } from 'node:path';
3
- import { determineElementRectangles, determineScreenRectangles, determineStatusAddressToolBarRectangles, determineIgnoreRegions, splitIgnores, determineDeviceBlockOuts, prepareIgnoreRectangles } from './rectangles.js';
3
+ import { determineElementRectangles, determineScreenRectangles, determineStatusAddressToolBarRectangles, determineIgnoreRegions, determineWebFullPageIgnoreRegions, determineWebScreenIgnoreRegions, determineWebElementIgnoreRegions, splitIgnores, determineDeviceBlockOuts, prepareIgnoreRectangles } from './rectangles.js';
4
4
  import { IMAGE_STRING } from '../mocks/image.js';
5
5
  vi.mock('@wdio/globals', () => ({
6
6
  browser: {
@@ -17,7 +17,9 @@ describe('rectangles', () => {
17
17
  mockGetElementRect = vi.fn();
18
18
  mockBrowserInstance = {
19
19
  execute: mockExecute,
20
- getElementRect: mockGetElementRect
20
+ getElementRect: mockGetElementRect,
21
+ $: vi.fn(),
22
+ $$: vi.fn(),
21
23
  };
22
24
  });
23
25
  afterEach(() => {
@@ -629,6 +631,361 @@ describe('rectangles', () => {
629
631
  .rejects.toThrow('Invalid elements or regions');
630
632
  });
631
633
  });
634
+ describe('determineWebScreenIgnoreRegions', () => {
635
+ const desktopOptions = {
636
+ browserInstance: null,
637
+ devicePixelRatio: 2,
638
+ deviceRectangles: baseDeviceRectangles,
639
+ isAndroid: false,
640
+ isAndroidNativeWebScreenshot: false,
641
+ isIOS: false,
642
+ ignoreRegionPadding: 0,
643
+ };
644
+ beforeEach(() => {
645
+ desktopOptions.browserInstance = mockBrowserInstance;
646
+ });
647
+ it('should resolve elements via raw BCR on desktop and apply DPR', async () => {
648
+ const mockElement = { elementId: 'el1', selector: '.nav' };
649
+ const freshElement = { elementId: 'el1-fresh', selector: '.nav' };
650
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([freshElement]);
651
+ mockExecute.mockResolvedValueOnce({ x: 10, y: 20, width: 200, height: 50 });
652
+ const result = await determineWebScreenIgnoreRegions(desktopOptions, [mockElement]);
653
+ expect(mockBrowserInstance.$$).toHaveBeenCalledWith('.nav');
654
+ expect(mockExecute).toHaveBeenCalledOnce();
655
+ expect(result).toEqual([
656
+ { x: 20, y: 40, width: 400, height: 100 },
657
+ ]);
658
+ });
659
+ it('should add DPR-scaled viewport offset on iOS and re-query elements via $$', async () => {
660
+ const iosDeviceRectangles = {
661
+ ...baseDeviceRectangles,
662
+ viewport: { y: 94, x: 0, width: 390, height: 650 },
663
+ };
664
+ const iosOptions = {
665
+ ...desktopOptions,
666
+ devicePixelRatio: 3,
667
+ deviceRectangles: iosDeviceRectangles,
668
+ isIOS: true,
669
+ };
670
+ const mockElement = { elementId: 'el1', selector: '.hero' };
671
+ const freshElement = { elementId: 'el1-fresh', selector: '.hero' };
672
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([freshElement]);
673
+ mockExecute.mockResolvedValueOnce({ x: 0, y: 100, width: 390, height: 200 });
674
+ const result = await determineWebScreenIgnoreRegions(iosOptions, [mockElement]);
675
+ expect(mockBrowserInstance.$$).toHaveBeenCalledWith('.hero');
676
+ expect(mockBrowserInstance.$).not.toHaveBeenCalled();
677
+ expect(result).toEqual([
678
+ { x: 0, y: 582, width: 1170, height: 600 },
679
+ ]);
680
+ });
681
+ it('should correctly resolve multiple elements sharing the same selector on iOS', async () => {
682
+ const iosDeviceRectangles = {
683
+ ...baseDeviceRectangles,
684
+ viewport: { y: 94, x: 0, width: 390, height: 650 },
685
+ };
686
+ const iosOptions = {
687
+ ...desktopOptions,
688
+ devicePixelRatio: 1,
689
+ deviceRectangles: iosDeviceRectangles,
690
+ isIOS: true,
691
+ };
692
+ const el1 = { elementId: 'a', selector: '.card' };
693
+ const el2 = { elementId: 'b', selector: '.card' };
694
+ const el3 = { elementId: 'c', selector: '.card' };
695
+ const fresh1 = { elementId: 'f1', selector: '.card' };
696
+ const fresh2 = { elementId: 'f2', selector: '.card' };
697
+ const fresh3 = { elementId: 'f3', selector: '.card' };
698
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([fresh1, fresh2, fresh3]);
699
+ mockExecute
700
+ .mockResolvedValueOnce({ x: 0, y: 100, width: 390, height: 50 })
701
+ .mockResolvedValueOnce({ x: 0, y: 200, width: 390, height: 50 })
702
+ .mockResolvedValueOnce({ x: 0, y: 300, width: 390, height: 50 });
703
+ const result = await determineWebScreenIgnoreRegions(iosOptions, [[el1, el2, el3]]);
704
+ // $$ called once for the shared selector, not $ three times
705
+ expect(mockBrowserInstance.$$).toHaveBeenCalledTimes(1);
706
+ expect(mockBrowserInstance.$$).toHaveBeenCalledWith('.card');
707
+ // execute called with each fresh element
708
+ expect(mockExecute).toHaveBeenCalledTimes(3);
709
+ // Each region has different y (viewport offset 94 added)
710
+ expect(result).toEqual([
711
+ { x: 0, y: 194, width: 390, height: 50 },
712
+ { x: 0, y: 294, width: 390, height: 50 },
713
+ { x: 0, y: 394, width: 390, height: 50 },
714
+ ]);
715
+ });
716
+ it('should add device-pixel viewport offset on Android native web screenshot', async () => {
717
+ const androidDeviceRectangles = {
718
+ ...baseDeviceRectangles,
719
+ // On Android, viewport offset is already in device pixels
720
+ // (injectWebviewOverlay pre-scales by DPR)
721
+ viewport: { y: 240, x: 0, width: 1236, height: 1956 },
722
+ };
723
+ const androidOptions = {
724
+ ...desktopOptions,
725
+ devicePixelRatio: 3,
726
+ deviceRectangles: androidDeviceRectangles,
727
+ isAndroid: true,
728
+ isAndroidNativeWebScreenshot: true,
729
+ };
730
+ const mockElement = { elementId: 'el1', selector: '#header' };
731
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([mockElement]);
732
+ mockExecute.mockResolvedValueOnce({ x: 0, y: 0, width: 412, height: 64 });
733
+ const result = await determineWebScreenIgnoreRegions(androidOptions, [mockElement]);
734
+ // BCR × DPR + viewport (already device px):
735
+ // x: 0*3 + 0 = 0, y: 0*3 + 240 = 240, w: 412*3 = 1236, h: 64*3 = 192
736
+ expect(result).toEqual([
737
+ { x: 0, y: 240, width: 1236, height: 192 },
738
+ ]);
739
+ });
740
+ it('should NOT add viewport offset on Android ChromeDriver screenshot', async () => {
741
+ const androidChromeOptions = {
742
+ ...desktopOptions,
743
+ devicePixelRatio: 3,
744
+ isAndroid: true,
745
+ isAndroidNativeWebScreenshot: false,
746
+ };
747
+ const mockElement = { elementId: 'el1', selector: '#header' };
748
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([mockElement]);
749
+ mockExecute.mockResolvedValueOnce({ x: 0, y: 0, width: 412, height: 64 });
750
+ const result = await determineWebScreenIgnoreRegions(androidChromeOptions, [mockElement]);
751
+ // BCR × DPR only, no viewport offset
752
+ expect(result).toEqual([
753
+ { x: 0, y: 0, width: 1236, height: 192 },
754
+ ]);
755
+ });
756
+ it('should apply DPR to coordinate regions as well', async () => {
757
+ const region = { x: 10, y: 20, width: 100, height: 150 };
758
+ const result = await determineWebScreenIgnoreRegions(desktopOptions, [region]);
759
+ expect(mockExecute).not.toHaveBeenCalled();
760
+ expect(result).toEqual([
761
+ { x: 20, y: 40, width: 200, height: 300 },
762
+ ]);
763
+ });
764
+ it('should handle mixed elements and regions with DPR applied to both', async () => {
765
+ const mockElement = { elementId: 'el1', selector: '.ad' };
766
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([mockElement]);
767
+ const region = { x: 500, y: 0, width: 200, height: 90 };
768
+ mockExecute.mockResolvedValueOnce({ x: 10, y: 20, width: 300, height: 80 });
769
+ const result = await determineWebScreenIgnoreRegions(desktopOptions, [mockElement, region]);
770
+ expect(result).toEqual([
771
+ { x: 1000, y: 0, width: 400, height: 180 },
772
+ { x: 20, y: 40, width: 600, height: 160 },
773
+ ]);
774
+ });
775
+ it('should handle empty array', async () => {
776
+ const result = await determineWebScreenIgnoreRegions(desktopOptions, []);
777
+ expect(result).toEqual([]);
778
+ expect(mockExecute).not.toHaveBeenCalled();
779
+ });
780
+ it('should handle chainable promise elements', async () => {
781
+ const chainableElement = Promise.resolve({ elementId: 'el1', selector: '.footer' });
782
+ const freshElement = { elementId: 'el1-fresh', selector: '.footer' };
783
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([freshElement]);
784
+ mockExecute.mockResolvedValueOnce({ x: 0, y: 900, width: 1200, height: 100 });
785
+ const result = await determineWebScreenIgnoreRegions(desktopOptions, [chainableElement]);
786
+ expect(result).toEqual([
787
+ { x: 0, y: 1800, width: 2400, height: 200 },
788
+ ]);
789
+ });
790
+ it('should use floor/ceil rounding on sub-pixel BCR values to fully cover elements', async () => {
791
+ const mockElement = { elementId: 'el1', selector: '.banner' };
792
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([mockElement]);
793
+ // Sub-pixel BCR values that would lose precision if rounded independently
794
+ mockExecute.mockResolvedValueOnce({ x: 0.33, y: 50.67, width: 412.5, height: 64.33 });
795
+ const opts = { ...desktopOptions, devicePixelRatio: 3 };
796
+ const result = await determineWebScreenIgnoreRegions(opts, [mockElement]);
797
+ // Position uses floor, far-edge uses ceil:
798
+ // x: floor(0.33*3) = floor(0.99) = 0
799
+ // y: floor(50.67*3) = floor(152.01) = 152
800
+ // right: ceil((0.33+412.5)*3) = ceil(1238.49) = 1239 → w = 1239-0 = 1239
801
+ // bottom: ceil((50.67+64.33)*3) = ceil(345.0) = 345 → h = 345-152 = 193
802
+ expect(result).toEqual([
803
+ { x: 0, y: 152, width: 1239, height: 193 },
804
+ ]);
805
+ });
806
+ it('should throw on invalid ignore items', async () => {
807
+ await expect(determineWebScreenIgnoreRegions(desktopOptions, ['invalid'])).rejects.toThrow('Invalid elements or regions');
808
+ });
809
+ it('should expand regions by ignoreRegionPadding (default 1) on each side', async () => {
810
+ const region = { x: 10, y: 20, width: 100, height: 50 };
811
+ const optionsWithDefaultPadding = {
812
+ ...desktopOptions,
813
+ ignoreRegionPadding: 1,
814
+ };
815
+ const result = await determineWebScreenIgnoreRegions(optionsWithDefaultPadding, [region]);
816
+ expect(mockExecute).not.toHaveBeenCalled();
817
+ // (10,20,100,50) × DPR 2 → (20,40,200,100); + padding 1 each side → (19,39,202,102)
818
+ expect(result).toEqual([
819
+ { x: 19, y: 39, width: 202, height: 102 },
820
+ ]);
821
+ });
822
+ it('should use custom ignoreRegionPadding when provided for screen', async () => {
823
+ const region = { x: 0, y: 0, width: 50, height: 20 };
824
+ const optionsWithPadding2 = {
825
+ ...desktopOptions,
826
+ ignoreRegionPadding: 2,
827
+ };
828
+ const result = await determineWebScreenIgnoreRegions(optionsWithPadding2, [region]);
829
+ // (0,0,50,20) × 2 → (0,0,100,40); + padding 2 → (0,0,104,44)
830
+ expect(result).toEqual([
831
+ { x: 0, y: 0, width: 104, height: 44 },
832
+ ]);
833
+ });
834
+ });
835
+ describe('determineWebFullPageIgnoreRegions', () => {
836
+ const fullPageOptions = {
837
+ browserInstance: null,
838
+ devicePixelRatio: 2,
839
+ ignoreRegionPadding: 0,
840
+ };
841
+ beforeEach(() => {
842
+ fullPageOptions.browserInstance = mockBrowserInstance;
843
+ });
844
+ it('should resolve elements via document BCR (BCR + scroll) and apply DPR', async () => {
845
+ const mockElement = { elementId: 'el1', selector: '.nav' };
846
+ const freshElement = { elementId: 'el1-fresh', selector: '.nav' };
847
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([freshElement]);
848
+ // rawDocumentBcr returns getBoundingClientRect() + (scrollX, scrollY) = document-relative CSS pixels
849
+ mockExecute.mockResolvedValueOnce({ x: 10, y: 1200, width: 200, height: 50 });
850
+ const result = await determineWebFullPageIgnoreRegions(fullPageOptions, [mockElement]);
851
+ expect(mockBrowserInstance.$$).toHaveBeenCalledWith('.nav');
852
+ expect(mockExecute).toHaveBeenCalledOnce();
853
+ // Document CSS (10, 1200, 200, 50) × DPR 2 → device pixels (20, 2400, 400, 100)
854
+ expect(result).toEqual([
855
+ { x: 20, y: 2400, width: 400, height: 100 },
856
+ ]);
857
+ });
858
+ it('should treat raw regions as document-relative CSS pixels and apply DPR', async () => {
859
+ const region = { x: 0, y: 500, width: 300, height: 80 };
860
+ const result = await determineWebFullPageIgnoreRegions(fullPageOptions, [region]);
861
+ expect(mockExecute).not.toHaveBeenCalled();
862
+ expect(result).toEqual([
863
+ { x: 0, y: 1000, width: 600, height: 160 },
864
+ ]);
865
+ });
866
+ it('should expand regions by ignoreRegionPadding', async () => {
867
+ const region = { x: 10, y: 20, width: 100, height: 50 };
868
+ const optionsWithPadding = {
869
+ ...fullPageOptions,
870
+ ignoreRegionPadding: 1,
871
+ };
872
+ const result = await determineWebFullPageIgnoreRegions(optionsWithPadding, [region]);
873
+ // (10,20,100,50) × 2 → (20,40,200,100); + padding 1 → (19,39,202,102)
874
+ expect(result).toEqual([
875
+ { x: 19, y: 39, width: 202, height: 102 },
876
+ ]);
877
+ });
878
+ it('should return empty array when ignores is empty', async () => {
879
+ const result = await determineWebFullPageIgnoreRegions(fullPageOptions, []);
880
+ expect(result).toEqual([]);
881
+ });
882
+ it('should subtract fullPageCropTopPaddingCSS from y for mobile scroll-and-stitch alignment', async () => {
883
+ const region = { x: 0, y: 100, width: 300, height: 80 };
884
+ const optionsWithCropTop = {
885
+ ...fullPageOptions,
886
+ devicePixelRatio: 3,
887
+ fullPageCropTopPaddingCSS: 6,
888
+ };
889
+ const result = await determineWebFullPageIgnoreRegions(optionsWithCropTop, [region]);
890
+ // document (0, 100, 300, 80) with cropTop 6 → canvas y = (100-6)*3 = 282, height = 80*3 = 240
891
+ expect(mockExecute).not.toHaveBeenCalled();
892
+ expect(result).toEqual([
893
+ { x: 0, y: 282, width: 900, height: 240 },
894
+ ]);
895
+ });
896
+ });
897
+ describe('determineWebElementIgnoreRegions', () => {
898
+ it('should resolve element-local regions and apply DPR', async () => {
899
+ const rootElement = { elementId: 'root', selector: '.root' };
900
+ const childElement = { elementId: 'child', selector: '.child' };
901
+ const freshChild = { elementId: 'child-fresh', selector: '.child' };
902
+ vi.mocked(mockBrowserInstance.$$).mockResolvedValueOnce([freshChild]);
903
+ // Simulate already-relative BCR from execute: (20,30,100,40)
904
+ mockExecute.mockResolvedValueOnce({ x: 20, y: 30, width: 100, height: 40 });
905
+ const result = await determineWebElementIgnoreRegions({
906
+ browserInstance: mockBrowserInstance,
907
+ devicePixelRatio: 2,
908
+ rootElement,
909
+ ignoreRegionPadding: 0,
910
+ }, [childElement]);
911
+ // CSS: (20,30,100,40) × DPR(2) → (40,60,200,80)
912
+ expect(result).toEqual([
913
+ { x: 40, y: 60, width: 200, height: 80 },
914
+ ]);
915
+ });
916
+ it('should pass through literal regions (CSS relative to element) with DPR applied', async () => {
917
+ const rootElement = { elementId: 'root', selector: '.root' };
918
+ const region = { x: 5, y: 10, width: 50, height: 20 };
919
+ const result = await determineWebElementIgnoreRegions({
920
+ browserInstance: mockBrowserInstance,
921
+ devicePixelRatio: 2,
922
+ rootElement,
923
+ ignoreRegionPadding: 0,
924
+ }, [region]);
925
+ expect(mockExecute).not.toHaveBeenCalled();
926
+ // (5,10,50,20) × 2 → (10,20,100,40)
927
+ expect(result).toEqual([
928
+ { x: 10, y: 20, width: 100, height: 40 },
929
+ ]);
930
+ });
931
+ it('should expand regions by ignoreRegionPadding (default 1) on each side', async () => {
932
+ const rootElement = { elementId: 'root', selector: '.root' };
933
+ const region = { x: 10, y: 20, width: 100, height: 40 };
934
+ const result = await determineWebElementIgnoreRegions({
935
+ browserInstance: mockBrowserInstance,
936
+ devicePixelRatio: 2,
937
+ rootElement,
938
+ ignoreRegionPadding: 1,
939
+ }, [region]);
940
+ expect(mockExecute).not.toHaveBeenCalled();
941
+ // (10,20,100,40) × 2 → (20,40,200,80); + padding 1 each side → (19,39,202,82)
942
+ expect(result).toEqual([
943
+ { x: 19, y: 39, width: 202, height: 82 },
944
+ ]);
945
+ });
946
+ it('should use custom ignoreRegionPadding when provided', async () => {
947
+ const rootElement = { elementId: 'root', selector: '.root' };
948
+ const region = { x: 0, y: 0, width: 50, height: 20 };
949
+ const result = await determineWebElementIgnoreRegions({
950
+ browserInstance: mockBrowserInstance,
951
+ devicePixelRatio: 1,
952
+ rootElement,
953
+ ignoreRegionPadding: 2,
954
+ }, [region]);
955
+ // (0,0,50,20) + padding 2 each side → (0,0,54,24) — x,y clamped to 0
956
+ expect(result).toEqual([
957
+ { x: 0, y: 0, width: 54, height: 24 },
958
+ ]);
959
+ });
960
+ it('should handle empty ignores', async () => {
961
+ const rootElement = { elementId: 'root', selector: '.root' };
962
+ const result = await determineWebElementIgnoreRegions({
963
+ browserInstance: mockBrowserInstance,
964
+ devicePixelRatio: 2,
965
+ rootElement,
966
+ ignoreRegionPadding: 0,
967
+ }, []);
968
+ expect(result).toEqual([]);
969
+ expect(mockExecute).not.toHaveBeenCalled();
970
+ });
971
+ it('should output CSS-pixel regions when isAndroidNativeWebScreenshot and isWebDriverElementScreenshot (native driver image at CSS size)', async () => {
972
+ const rootElement = { elementId: 'root', selector: '.root' };
973
+ const region = { x: 10, y: 20, width: 100, height: 40 };
974
+ const result = await determineWebElementIgnoreRegions({
975
+ browserInstance: mockBrowserInstance,
976
+ devicePixelRatio: 2,
977
+ rootElement,
978
+ ignoreRegionPadding: 0,
979
+ isAndroidNativeWebScreenshot: true,
980
+ isWebDriverElementScreenshot: true,
981
+ }, [region]);
982
+ expect(mockExecute).not.toHaveBeenCalled();
983
+ // Device px: (10,20,100,40) × 2 → (20,40,200,80); then downscale to CSS for native driver image → (10,20,100,40)
984
+ expect(result).toEqual([
985
+ { x: 10, y: 20, width: 100, height: 40 },
986
+ ]);
987
+ });
988
+ });
632
989
  describe('determineDeviceBlockOuts', () => {
633
990
  it('should return empty array when no blockouts are enabled', async () => {
634
991
  const options = createDeviceBlockOutsOptions();
@@ -849,5 +1206,204 @@ describe('rectangles', () => {
849
1206
  expect(result.hasIgnoreRectangles).toBe(true);
850
1207
  expect(result.ignoredBoxes).toMatchSnapshot();
851
1208
  });
1209
+ it('should scale ignoreRegions by DPR for native iOS app (logical → device pixels)', async () => {
1210
+ const options = createPrepareIgnoreRectanglesOptions({
1211
+ ignoreRegions: [
1212
+ { x: 10, y: 20, width: 100, height: 50 },
1213
+ ],
1214
+ devicePixelRatio: 3,
1215
+ isMobile: true,
1216
+ isNativeContext: true,
1217
+ isAndroid: false,
1218
+ });
1219
+ const result = await prepareIgnoreRectangles(options);
1220
+ expect(result.hasIgnoreRectangles).toBe(true);
1221
+ expect(result.ignoredBoxes).toHaveLength(1);
1222
+ expect(result.ignoredBoxes[0]).toEqual({
1223
+ left: 30,
1224
+ top: 60,
1225
+ right: 330,
1226
+ bottom: 210,
1227
+ });
1228
+ });
1229
+ it('should scale multiple ignoreRegions by DPR for native iOS app', async () => {
1230
+ const options = createPrepareIgnoreRectanglesOptions({
1231
+ ignoreRegions: [
1232
+ { x: 0, y: 0, width: 390, height: 47 },
1233
+ { x: 50, y: 100, width: 200, height: 80 },
1234
+ ],
1235
+ devicePixelRatio: 3,
1236
+ isMobile: true,
1237
+ isNativeContext: true,
1238
+ isAndroid: false,
1239
+ });
1240
+ const result = await prepareIgnoreRectangles(options);
1241
+ expect(result.hasIgnoreRectangles).toBe(true);
1242
+ expect(result.ignoredBoxes).toHaveLength(2);
1243
+ expect(result.ignoredBoxes[0]).toEqual({
1244
+ left: 0,
1245
+ top: 0,
1246
+ right: 1170,
1247
+ bottom: 141,
1248
+ });
1249
+ expect(result.ignoredBoxes[1]).toEqual({
1250
+ left: 150,
1251
+ top: 300,
1252
+ right: 750,
1253
+ bottom: 540,
1254
+ });
1255
+ });
1256
+ it('should not scale ignoreRegions for native Android app', async () => {
1257
+ const options = createPrepareIgnoreRectanglesOptions({
1258
+ ignoreRegions: [{ x: 100, y: 200, width: 150, height: 75 }],
1259
+ devicePixelRatio: 3,
1260
+ isMobile: true,
1261
+ isNativeContext: true,
1262
+ isAndroid: true,
1263
+ });
1264
+ const result = await prepareIgnoreRectangles(options);
1265
+ expect(result.hasIgnoreRectangles).toBe(true);
1266
+ expect(result.ignoredBoxes).toHaveLength(1);
1267
+ expect(result.ignoredBoxes[0]).toEqual({
1268
+ left: 100,
1269
+ top: 200,
1270
+ right: 250,
1271
+ bottom: 275,
1272
+ });
1273
+ });
1274
+ it('should not scale ignoreRegions when not native context (e.g. web)', async () => {
1275
+ const options = createPrepareIgnoreRectanglesOptions({
1276
+ ignoreRegions: [{ x: 10, y: 20, width: 100, height: 50 }],
1277
+ devicePixelRatio: 3,
1278
+ isMobile: true,
1279
+ isNativeContext: false,
1280
+ isAndroid: false,
1281
+ });
1282
+ const result = await prepareIgnoreRectangles(options);
1283
+ expect(result.hasIgnoreRectangles).toBe(true);
1284
+ expect(result.ignoredBoxes).toHaveLength(1);
1285
+ expect(result.ignoredBoxes[0]).toEqual({
1286
+ left: 10,
1287
+ top: 20,
1288
+ right: 110,
1289
+ bottom: 70,
1290
+ });
1291
+ });
1292
+ it('should add hybrid-app status bar fallback when statusBarAndAddressBar height is 0 (iOS)', async () => {
1293
+ const deviceRectangles = createDeviceRectanglesWithData({
1294
+ screenSize: { width: 375, height: 812 },
1295
+ statusBarAndAddressBar: { x: 0, y: 0, width: 375, height: 0 },
1296
+ bottomBar: { y: 0, x: 0, width: 0, height: 0 },
1297
+ });
1298
+ const options = createPrepareIgnoreRectanglesOptions({
1299
+ deviceRectangles,
1300
+ isMobile: true,
1301
+ isNativeContext: false,
1302
+ isAndroid: false,
1303
+ isAndroidNativeWebScreenshot: true,
1304
+ isViewPortScreenshot: true,
1305
+ devicePixelRatio: 3,
1306
+ imageCompareOptions: {
1307
+ blockOutStatusBar: true,
1308
+ },
1309
+ });
1310
+ const result = await prepareIgnoreRectangles(options);
1311
+ expect(result.hasIgnoreRectangles).toBe(true);
1312
+ const statusBarBox = result.ignoredBoxes.find((b) => b.top === 0);
1313
+ expect(statusBarBox).toBeDefined();
1314
+ expect(statusBarBox.left).toBe(0);
1315
+ expect(statusBarBox.top).toBe(0);
1316
+ expect(statusBarBox.right).toBe(1125);
1317
+ expect(statusBarBox.bottom).toBe(132);
1318
+ });
1319
+ it('should add hybrid-app status bar fallback when isHybridApp is true (iOS)', async () => {
1320
+ const deviceRectangles = createDeviceRectanglesWithData({
1321
+ screenSize: { width: 390, height: 844 },
1322
+ statusBarAndAddressBar: { x: 0, y: 0, width: 390, height: 47 },
1323
+ });
1324
+ const options = createPrepareIgnoreRectanglesOptions({
1325
+ deviceRectangles,
1326
+ isMobile: true,
1327
+ isNativeContext: false,
1328
+ isAndroid: false,
1329
+ isAndroidNativeWebScreenshot: true,
1330
+ isViewPortScreenshot: true,
1331
+ devicePixelRatio: 2,
1332
+ isHybridApp: true,
1333
+ imageCompareOptions: {
1334
+ blockOutStatusBar: true,
1335
+ },
1336
+ });
1337
+ const result = await prepareIgnoreRectangles(options);
1338
+ expect(result.hasIgnoreRectangles).toBe(true);
1339
+ const statusBarBoxes = result.ignoredBoxes.filter((b) => b.top === 0);
1340
+ expect(statusBarBoxes.length).toBeGreaterThanOrEqual(1);
1341
+ });
1342
+ it('should add hybrid-app status bar fallback for Android when overlay reports zero', async () => {
1343
+ const deviceRectangles = createDeviceRectanglesWithData({
1344
+ screenSize: { width: 412, height: 869 },
1345
+ statusBarAndAddressBar: { x: 0, y: 0, width: 412, height: 0 },
1346
+ bottomBar: { y: 0, x: 0, width: 0, height: 0 },
1347
+ });
1348
+ const options = createPrepareIgnoreRectanglesOptions({
1349
+ deviceRectangles,
1350
+ isMobile: true,
1351
+ isNativeContext: false,
1352
+ isAndroid: true,
1353
+ isAndroidNativeWebScreenshot: true,
1354
+ isViewPortScreenshot: true,
1355
+ devicePixelRatio: 2,
1356
+ imageCompareOptions: {
1357
+ blockOutStatusBar: true,
1358
+ },
1359
+ });
1360
+ const result = await prepareIgnoreRectangles(options);
1361
+ expect(result.hasIgnoreRectangles).toBe(true);
1362
+ const statusBarBox = result.ignoredBoxes.find((b) => b.top === 0);
1363
+ expect(statusBarBox).toBeDefined();
1364
+ expect(statusBarBox.bottom).toBe(24);
1365
+ });
1366
+ it('should use device platformVersion for Android hybrid status bar fallback when in ANDROID_OFFSETS list', async () => {
1367
+ const deviceRectangles = createDeviceRectanglesWithData({
1368
+ screenSize: { width: 412, height: 869 },
1369
+ statusBarAndAddressBar: { x: 0, y: 0, width: 412, height: 0 },
1370
+ });
1371
+ const options = createPrepareIgnoreRectanglesOptions({
1372
+ deviceRectangles,
1373
+ isMobile: true,
1374
+ isNativeContext: false,
1375
+ isAndroid: true,
1376
+ isViewPortScreenshot: true,
1377
+ devicePixelRatio: 2,
1378
+ imageCompareOptions: { blockOutStatusBar: true },
1379
+ platformVersion: '12',
1380
+ });
1381
+ const result = await prepareIgnoreRectangles(options);
1382
+ expect(result.hasIgnoreRectangles).toBe(true);
1383
+ const statusBarBox = result.ignoredBoxes.find((b) => b.top === 0);
1384
+ expect(statusBarBox).toBeDefined();
1385
+ expect(statusBarBox.bottom).toBe(24);
1386
+ });
1387
+ it('should fall back to latest API level for Android when platformVersion not in ANDROID_OFFSETS', async () => {
1388
+ const deviceRectangles = createDeviceRectanglesWithData({
1389
+ screenSize: { width: 412, height: 869 },
1390
+ statusBarAndAddressBar: { x: 0, y: 0, width: 412, height: 0 },
1391
+ });
1392
+ const options = createPrepareIgnoreRectanglesOptions({
1393
+ deviceRectangles,
1394
+ isMobile: true,
1395
+ isNativeContext: false,
1396
+ isAndroid: true,
1397
+ isViewPortScreenshot: true,
1398
+ devicePixelRatio: 2,
1399
+ imageCompareOptions: { blockOutStatusBar: true },
1400
+ platformVersion: '99',
1401
+ });
1402
+ const result = await prepareIgnoreRectangles(options);
1403
+ expect(result.hasIgnoreRectangles).toBe(true);
1404
+ const statusBarBox = result.ignoredBoxes.find((b) => b.top === 0);
1405
+ expect(statusBarBox).toBeDefined();
1406
+ expect(statusBarBox.bottom).toBe(24);
1407
+ });
852
1408
  });
853
1409
  });
@@ -12,25 +12,28 @@ export async function takeElementScreenshot(browserInstance, options, shouldUseB
12
12
  return await takeWebDriverElementScreenshot(browserInstance, options);
13
13
  }
14
14
  async function takeBiDiElementScreenshot(browserInstance, options) {
15
- let base64Image;
16
15
  const isWebDriverElementScreenshot = false;
17
- // We also need to clip the image to the element size, taking into account the DPR
18
- // and also clip it from the document, not the viewport
16
+ // Scroll the element into the viewport so any lazy‑load / intersection
17
+ // observers are triggered. We always capture from the *document* origin,
18
+ // so the clip coordinates are document‑relative and independent of scroll.
19
+ let currentPosition;
20
+ if (options.autoElementScroll) {
21
+ currentPosition = await browserInstance.execute(scrollElementIntoView, options.element, options.addressBarShadowPadding);
22
+ await waitFor(100);
23
+ }
24
+ // Get the element rect and clip the screenshot. WebDriver getElementRect
25
+ // returns coordinates relative to the document origin, which matches the
26
+ // BiDi `origin: 'document'` coordinate system.
19
27
  const rect = await browserInstance.getElementRect((await options.element).elementId);
20
28
  const clip = { x: Math.floor(rect.x), y: Math.floor(rect.y), width: Math.floor(rect.width), height: Math.floor(rect.height) };
21
- const takeBiDiElementScreenshot = (origin) => takeBase64BiDiScreenshot({ browserInstance, origin, clip });
22
- try {
23
- // By default we take the screenshot from the viewport
24
- base64Image = await takeBiDiElementScreenshot('viewport');
25
- }
26
- catch (err) {
27
- // But when we get a zero dimension error (meaning the element might be bigger than the
28
- // viewport or it might not be in the viewport), we need to take the screenshot from the document.
29
- const isZeroDimensionError = typeof err?.message === 'string' && err.message.includes('WebDriver Bidi command "browsingContext.captureScreenshot" failed with error: unable to capture screen - Unable to capture screenshot with zero dimensions');
30
- if (!isZeroDimensionError) {
31
- throw err;
32
- }
33
- base64Image = await takeBiDiElementScreenshot('document');
29
+ const base64Image = await takeBase64BiDiScreenshot({
30
+ browserInstance,
31
+ origin: 'document',
32
+ clip,
33
+ });
34
+ // Restore scroll position
35
+ if (options.autoElementScroll && currentPosition) {
36
+ await browserInstance.execute(scrollToPosition, currentPosition);
34
37
  }
35
38
  return {
36
39
  base64Image,