From 3248f44cf216557c863c08b2dcf150ff319f4eec Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 11 Apr 2025 14:19:09 +0700 Subject: [PATCH] TF-3622 Add integration test for case deformed inlined image Signed-off-by: dab246 --- .../html_content_viewer_widget.dart | 7 +- core/lib/utils/html/html_interaction.dart | 82 +++++++-- core/lib/utils/platform_info.dart | 1 + .../deformed_inlined_image_scenario.dart | 158 ++++++++++++++++++ .../deformed_inlined_image_test.dart | 9 + .../controller/single_email_controller.dart | 7 + .../email/presentation/email_view.dart | 2 + 7 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 integration_test/scenarios/email_detailed/deformed_inlined_image_scenario.dart create mode 100644 integration_test/tests/email_detailed/deformed_inlined_image_test.dart diff --git a/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart b/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart index b4d3514c8..1ba183c76 100644 --- a/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart +++ b/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart @@ -60,10 +60,10 @@ class HtmlContentViewer extends StatefulWidget { }) : super(key: key); @override - State createState() => _HtmlContentViewState(); + State createState() => HtmlContentViewState(); } -class _HtmlContentViewState extends State { +class HtmlContentViewState extends State { late InAppWebViewController _webViewController; late double _actualHeight; @@ -79,6 +79,9 @@ class _HtmlContentViewState extends State { verticalScrollBarEnabled: false, ); + @visibleForTesting + InAppWebViewController get webViewController => _webViewController; + @override void initState() { super.initState(); diff --git a/core/lib/utils/html/html_interaction.dart b/core/lib/utils/html/html_interaction.dart index cc51c5b3f..63a6104e7 100644 --- a/core/lib/utils/html/html_interaction.dart +++ b/core/lib/utils/html/html_interaction.dart @@ -120,6 +120,7 @@ class HtmlInteraction { } function removeWidthHeightFromStyle(style) { + // Remove width and height properties from style string style = style.replace(/width\\s*:\\s*[\\d.]+[a-zA-Z%]+\\s*;?/gi, ''); style = style.replace(/height\\s*:\\s*[\\d.]+[a-zA-Z%]+\\s*;?/gi, ''); style = style.trim(); @@ -130,6 +131,7 @@ class HtmlInteraction { } function extractWidthHeightFromStyle(style) { + // Extract width and height values with units from style string const result = {}; const widthMatch = style.match(/width\\s*:\\s*([\\d.]+)([a-zA-Z%]+)\\s*;?/); const heightMatch = style.match(/height\\s*:\\s*([\\d.]+)([a-zA-Z%]+)\\s*;?/); @@ -154,9 +156,24 @@ class HtmlInteraction { } function normalizeStyleAttribute(attrs) { + // Normalize style attribute to ensure proper responsive behavior let style = attrs['style']; + const widthStr = attrs['width']; + if (!style) { - attrs['style'] = 'display:inline;max-width:100%;'; + if (!widthStr) { + attrs['style'] = 'max-width: 100%;height: auto;display: inline;'; + } else { + if (!style.includes('height')) { + style += 'height: auto;'; + } + + if (!style.includes('display')) { + style += 'display: inline;'; + } + + attrs['style'] = style; + } return; } @@ -175,40 +192,59 @@ class HtmlInteraction { } } + // Ensure proper style string formatting if (style.length && !style.endsWith(';')) { style += ';'; } + // Add responsive defaults if missing if (!style.includes('max-width')) { - style += 'max-width:100%;height:auto;'; + style += 'max-width: 100%;'; + } + + if (!style.includes('height')) { + style += 'height: auto;'; } if (!style.includes('display')) { - style += 'display:inline;'; + style += 'display: inline;'; } attrs['style'] = style; } function normalizeWidthHeightAttribute(attrs) { + // Normalize width/height attributes and remove if necessary const widthStr = attrs['width']; const heightStr = attrs['height']; - if (!widthStr || displayWidth === undefined) return; + // Remove attribute if value is null or undefined + if (widthStr === null || widthStr === undefined) { + delete attrs['width']; + } else if (displayWidth !== undefined) { + const widthValue = parseFloat(widthStr); + if (!isNaN(widthValue)) { + if (widthValue > displayWidth) { + delete attrs['width']; + delete attrs['height']; + } + } + } - const widthValue = parseFloat(widthStr); - if (isNaN(widthValue) || widthValue <= displayWidth) return; - - delete attrs['width']; - delete attrs['height']; + // Remove height attribute if value is null or undefined + if (heightStr === null || heightStr === undefined) { + delete attrs['height']; + } } function normalizeImageSize(attrs) { - normalizeStyleAttribute(attrs); + // Apply both style and attribute normalization normalizeWidthHeightAttribute(attrs); + normalizeStyleAttribute(attrs); } function applyImageNormalization() { + // Process all images on the page document.querySelectorAll('img').forEach(img => { const attrs = { style: img.getAttribute('style'), @@ -218,16 +254,31 @@ class HtmlInteraction { normalizeImageSize(attrs); - if (attrs.style != null) img.setAttribute('style', attrs.style); - if ('width' in attrs) img.setAttribute('width', attrs.width); - else img.removeAttribute('width'); + // Handle style attribute + if (attrs.style !== null && attrs.style !== undefined) { + img.setAttribute('style', attrs.style); + } else { + img.removeAttribute('style'); + } - if ('height' in attrs) img.setAttribute('height', attrs.height); - else img.removeAttribute('height'); + // Handle width attribute + if ('width' in attrs && attrs.width !== null && attrs.width !== undefined) { + img.setAttribute('width', attrs.width); + } else { + img.removeAttribute('width'); + } + + // Handle height attribute + if ('height' in attrs && attrs.height !== null && attrs.height !== undefined) { + img.setAttribute('height', attrs.height); + } else { + img.removeAttribute('height'); + } }); } function safeApplyImageNormalization() { + // Error-safe wrapper for the normalization function try { applyImageNormalization(); } catch (e) { @@ -235,6 +286,7 @@ class HtmlInteraction { } } + // Run normalization when page loads window.onload = safeApplyImageNormalization; '''; diff --git a/core/lib/utils/platform_info.dart b/core/lib/utils/platform_info.dart index f7f0edc19..7a5ff5c91 100644 --- a/core/lib/utils/platform_info.dart +++ b/core/lib/utils/platform_info.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; abstract class PlatformInfo { @visibleForTesting static bool isTestingForWeb = false; + static bool isIntegrationTesting = false; static bool get isWeb => kIsWeb || isTestingForWeb; static bool get isLinux => !isWeb && defaultTargetPlatform == TargetPlatform.linux; diff --git a/integration_test/scenarios/email_detailed/deformed_inlined_image_scenario.dart b/integration_test/scenarios/email_detailed/deformed_inlined_image_scenario.dart new file mode 100644 index 000000000..afd608aa8 --- /dev/null +++ b/integration_test/scenarios/email_detailed/deformed_inlined_image_scenario.dart @@ -0,0 +1,158 @@ +import 'package:core/presentation/views/html_viewer/html_content_viewer_widget.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/email_view.dart'; +import 'package:tmail_ui_user/features/thread/presentation/widgets/email_tile_builder.dart'; + +import '../../base/base_test_scenario.dart'; +import '../../models/provisioning_email.dart'; +import '../../robots/thread_robot.dart'; + +class DeformedInlinedImageScenario extends BaseTestScenario { + const DeformedInlinedImageScenario(super.$); + + static const String subject = 'Deformed inlined image'; + static const String content = ''' + inline-image-no-style +
+ inline-image-oversize-with-style +
+ inline-image-oversize-with-width-attribute +
+ inline-image-oversize-with-style-and-width-attribute +
+ inline-image-normal-size-with-width-attribute +
+ inline-image-normal-size-with-style-and-width-attribute + '''; + + @override + Future runTestLogic() async { + PlatformInfo.isIntegrationTesting = true; + + const emailUser = String.fromEnvironment('BASIC_AUTH_EMAIL'); + final provisioningEmail = ProvisioningEmail( + toEmail: emailUser, + subject: subject, + content: content, + ); + + await provisionEmail([provisioningEmail], requestReadReceipt: false); + await $.pumpAndSettle(duration: const Duration(seconds: 3)); + await _expectDisplayedEmailWithSubject(); + + final threadRobot = ThreadRobot($); + await threadRobot.openEmailWithSubject(subject); + await $.pumpAndSettle(duration: const Duration(seconds: 3)); + await _expectEmailViewDisplaysNormalizedInlineImages(); + + PlatformInfo.isIntegrationTesting = false; + } + + Future _expectDisplayedEmailWithSubject() async { + await expectViewVisible( + $(EmailTileBuilder).which( + (widget) => widget.presentationEmail.subject == subject + ), + ); + } + + Future _expectEmailViewDisplaysNormalizedInlineImages() async { + HtmlContentViewer? htmlContentViewer; + await $(HtmlContentViewer) + .which((view) { + htmlContentViewer = view; + return true; + }) + .first + .tap(); + expect(htmlContentViewer, isNotNull); + log('DeformedInlinedImageScenario::_expectEmailViewDisplaysNormalizedInlineImages:initialWidth = ${htmlContentViewer?.initialWidth}'); + expect(htmlContentViewer?.contentHtml.isNotEmpty, isTrue); + + SingleEmailController? emailController; + await $(EmailView) + .which((view) { + emailController = view.controller; + return true; + }) + .first + .tap(); + expect(emailController, isNotNull); + + final webViewController = emailController + ?.htmlContentViewKey + ?.currentState + ?.webViewController; + expect(webViewController, isNotNull); + + final result = await webViewController?.evaluateJavascript( + source: ''' + (function() { + const images = document.querySelectorAll('img'); + return Array.from(images).map(img => ({ + src: img.getAttribute('src'), + width: img.getAttribute('width'), + height: img.getAttribute('height'), + style: img.getAttribute('style'), + alt: img.getAttribute('alt'), + outerHTML: img.outerHTML + })); + })(); + ''' + ); + + final imageList = List>.from(result); + + expect(imageList.length, 6); + + for (var img in imageList) { + final imgWidth= img['width']; + final imgHeight = img['height']; + final imgStyle = img['style']; + final imgAlt = img['alt']; + final imgOuterHTML = img['outerHTML']; + log("DeformedInlinedImageScenario::_expectEmailViewDisplaysNormalizedInlineImages:Image $imgOuterHTML"); + + if (imgAlt == 'inline-image-normal-size-with-width-attribute') { + expect(imgStyle, isNull); + expect(imgWidth, equals('100')); + expect(imgHeight, equals('100')); + } else if (imgAlt == 'inline-image-normal-size-with-style-and-width-attribute') { + expect(imgStyle.contains('max-width: 100%;'), isFalse); + expect(imgStyle.contains('width: 100px; height: 100px;'), isTrue); + expect(imgWidth, equals('100')); + expect(imgHeight, equals('100')); + } else { + expect(imgStyle, equals('max-width: 100%;height: auto;display: inline;')); + expect(imgWidth, isNull); + expect(imgHeight, isNull); + } + } + } +} \ No newline at end of file diff --git a/integration_test/tests/email_detailed/deformed_inlined_image_test.dart b/integration_test/tests/email_detailed/deformed_inlined_image_test.dart new file mode 100644 index 000000000..58a18bbce --- /dev/null +++ b/integration_test/tests/email_detailed/deformed_inlined_image_test.dart @@ -0,0 +1,9 @@ +import '../../base/test_base.dart'; +import '../../scenarios/email_detailed/deformed_inlined_image_scenario.dart'; + +void main() { + TestBase().runPatrolTest( + description: 'Should see normalized inline image when open email with content contain inline image', + scenarioBuilder: ($) => DeformedInlinedImageScenario($), + ); +} \ No newline at end of file diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index e244c7f20..f9de811fa 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -196,6 +196,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final isEmailContentClipped = RxBool(false); final attendanceStatus = Rxn(); + GlobalKey? htmlContentViewKey; EmailId? _currentEmailId; Identity? _identitySelected; ButtonState? _printEmailButtonState; @@ -243,6 +244,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin { void onInit() { _registerObxStreamListener(); _listenDownloadAttachmentProgressState(); + if (PlatformInfo.isIntegrationTesting) { + htmlContentViewKey = GlobalKey(); + } super.onInit(); } @@ -250,6 +254,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin { void onClose() { _downloadProgressStateController.close(); _attachmentListScrollController.dispose(); + if (PlatformInfo.isIntegrationTesting) { + htmlContentViewKey = null; + } super.onClose(); } diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index ad5733132..29d4c642a 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -447,6 +447,7 @@ class EmailView extends GetWidget { ), child: LayoutBuilder(builder: (context, constraints) { return HtmlContentViewer( + key: controller.htmlContentViewKey, contentHtml: allEmailContents, initialWidth: constraints.maxWidth, direction: AppUtils.getCurrentDirection(context), @@ -486,6 +487,7 @@ class EmailView extends GetWidget { ), child: LayoutBuilder(builder: (context, constraints) { return HtmlContentViewer( + key: controller.htmlContentViewKey, contentHtml: allEmailContents, initialWidth: constraints.maxWidth, direction: AppUtils.getCurrentDirection(context),