TF-3622 Add integration test for case deformed inlined image

Signed-off-by: dab246 <tdvu@linagora.com>
This commit is contained in:
dab246
2025-04-11 14:19:09 +07:00
committed by Dat H. Pham
parent 551ef15276
commit 3248f44cf2
7 changed files with 249 additions and 17 deletions
@@ -60,10 +60,10 @@ class HtmlContentViewer extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
@override @override
State<StatefulWidget> createState() => _HtmlContentViewState(); State<StatefulWidget> createState() => HtmlContentViewState();
} }
class _HtmlContentViewState extends State<HtmlContentViewer> { class HtmlContentViewState extends State<HtmlContentViewer> {
late InAppWebViewController _webViewController; late InAppWebViewController _webViewController;
late double _actualHeight; late double _actualHeight;
@@ -79,6 +79,9 @@ class _HtmlContentViewState extends State<HtmlContentViewer> {
verticalScrollBarEnabled: false, verticalScrollBarEnabled: false,
); );
@visibleForTesting
InAppWebViewController get webViewController => _webViewController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
+67 -15
View File
@@ -120,6 +120,7 @@ class HtmlInteraction {
} }
function removeWidthHeightFromStyle(style) { 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(/width\\s*:\\s*[\\d.]+[a-zA-Z%]+\\s*;?/gi, '');
style = style.replace(/height\\s*:\\s*[\\d.]+[a-zA-Z%]+\\s*;?/gi, ''); style = style.replace(/height\\s*:\\s*[\\d.]+[a-zA-Z%]+\\s*;?/gi, '');
style = style.trim(); style = style.trim();
@@ -130,6 +131,7 @@ class HtmlInteraction {
} }
function extractWidthHeightFromStyle(style) { function extractWidthHeightFromStyle(style) {
// Extract width and height values with units from style string
const result = {}; const result = {};
const widthMatch = style.match(/width\\s*:\\s*([\\d.]+)([a-zA-Z%]+)\\s*;?/); const widthMatch = style.match(/width\\s*:\\s*([\\d.]+)([a-zA-Z%]+)\\s*;?/);
const heightMatch = style.match(/height\\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) { function normalizeStyleAttribute(attrs) {
// Normalize style attribute to ensure proper responsive behavior
let style = attrs['style']; let style = attrs['style'];
const widthStr = attrs['width'];
if (!style) { 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; return;
} }
@@ -175,40 +192,59 @@ class HtmlInteraction {
} }
} }
// Ensure proper style string formatting
if (style.length && !style.endsWith(';')) { if (style.length && !style.endsWith(';')) {
style += ';'; style += ';';
} }
// Add responsive defaults if missing
if (!style.includes('max-width')) { 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')) { if (!style.includes('display')) {
style += 'display:inline;'; style += 'display: inline;';
} }
attrs['style'] = style; attrs['style'] = style;
} }
function normalizeWidthHeightAttribute(attrs) { function normalizeWidthHeightAttribute(attrs) {
// Normalize width/height attributes and remove if necessary
const widthStr = attrs['width']; const widthStr = attrs['width'];
const heightStr = attrs['height']; 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); // Remove height attribute if value is null or undefined
if (isNaN(widthValue) || widthValue <= displayWidth) return; if (heightStr === null || heightStr === undefined) {
delete attrs['height'];
delete attrs['width']; }
delete attrs['height'];
} }
function normalizeImageSize(attrs) { function normalizeImageSize(attrs) {
normalizeStyleAttribute(attrs); // Apply both style and attribute normalization
normalizeWidthHeightAttribute(attrs); normalizeWidthHeightAttribute(attrs);
normalizeStyleAttribute(attrs);
} }
function applyImageNormalization() { function applyImageNormalization() {
// Process all images on the page
document.querySelectorAll('img').forEach(img => { document.querySelectorAll('img').forEach(img => {
const attrs = { const attrs = {
style: img.getAttribute('style'), style: img.getAttribute('style'),
@@ -218,16 +254,31 @@ class HtmlInteraction {
normalizeImageSize(attrs); normalizeImageSize(attrs);
if (attrs.style != null) img.setAttribute('style', attrs.style); // Handle style attribute
if ('width' in attrs) img.setAttribute('width', attrs.width); if (attrs.style !== null && attrs.style !== undefined) {
else img.removeAttribute('width'); img.setAttribute('style', attrs.style);
} else {
img.removeAttribute('style');
}
if ('height' in attrs) img.setAttribute('height', attrs.height); // Handle width attribute
else img.removeAttribute('height'); 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() { function safeApplyImageNormalization() {
// Error-safe wrapper for the normalization function
try { try {
applyImageNormalization(); applyImageNormalization();
} catch (e) { } catch (e) {
@@ -235,6 +286,7 @@ class HtmlInteraction {
} }
} }
// Run normalization when page loads
window.onload = safeApplyImageNormalization; window.onload = safeApplyImageNormalization;
</script> </script>
'''; ''';
+1
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
abstract class PlatformInfo { abstract class PlatformInfo {
@visibleForTesting @visibleForTesting
static bool isTestingForWeb = false; static bool isTestingForWeb = false;
static bool isIntegrationTesting = false;
static bool get isWeb => kIsWeb || isTestingForWeb; static bool get isWeb => kIsWeb || isTestingForWeb;
static bool get isLinux => !isWeb && defaultTargetPlatform == TargetPlatform.linux; static bool get isLinux => !isWeb && defaultTargetPlatform == TargetPlatform.linux;
@@ -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 = '''
<img
src="https://example.com/image.jpg"
alt="inline-image-no-style"/>
<br>
<img
src="https://example.com/image.jpg"
style="width: 2000px; height: 200px;"
alt="inline-image-oversize-with-style"/>
<br>
<img
src="https://example.com/image.jpg"
width="2000"
height="200"
alt="inline-image-oversize-with-width-attribute"/>
<br>
<img
src="https://example.com/image.jpg"
style="width: 2000px; height: 200px;"
width="2000"
height="200"
alt="inline-image-oversize-with-style-and-width-attribute"/>
<br>
<img
src="https://example.com/image.jpg"
width="100"
height="100"
alt="inline-image-normal-size-with-width-attribute"/>
<br>
<img
src="https://example.com/image.jpg"
style="width: 100px; height: 100px;"
width="100"
height="100"
alt="inline-image-normal-size-with-style-and-width-attribute"/>
''';
@override
Future<void> 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<void> _expectDisplayedEmailWithSubject() async {
await expectViewVisible(
$(EmailTileBuilder).which<EmailTileBuilder>(
(widget) => widget.presentationEmail.subject == subject
),
);
}
Future<void> _expectEmailViewDisplaysNormalizedInlineImages() async {
HtmlContentViewer? htmlContentViewer;
await $(HtmlContentViewer)
.which<HtmlContentViewer>((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<EmailView>((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<Map<String, dynamic>>.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);
}
}
}
}
@@ -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($),
);
}
@@ -196,6 +196,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
final isEmailContentClipped = RxBool(false); final isEmailContentClipped = RxBool(false);
final attendanceStatus = Rxn<AttendanceStatus>(); final attendanceStatus = Rxn<AttendanceStatus>();
GlobalKey<HtmlContentViewState>? htmlContentViewKey;
EmailId? _currentEmailId; EmailId? _currentEmailId;
Identity? _identitySelected; Identity? _identitySelected;
ButtonState? _printEmailButtonState; ButtonState? _printEmailButtonState;
@@ -243,6 +244,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
void onInit() { void onInit() {
_registerObxStreamListener(); _registerObxStreamListener();
_listenDownloadAttachmentProgressState(); _listenDownloadAttachmentProgressState();
if (PlatformInfo.isIntegrationTesting) {
htmlContentViewKey = GlobalKey<HtmlContentViewState>();
}
super.onInit(); super.onInit();
} }
@@ -250,6 +254,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
void onClose() { void onClose() {
_downloadProgressStateController.close(); _downloadProgressStateController.close();
_attachmentListScrollController.dispose(); _attachmentListScrollController.dispose();
if (PlatformInfo.isIntegrationTesting) {
htmlContentViewKey = null;
}
super.onClose(); super.onClose();
} }
@@ -447,6 +447,7 @@ class EmailView extends GetWidget<SingleEmailController> {
), ),
child: LayoutBuilder(builder: (context, constraints) { child: LayoutBuilder(builder: (context, constraints) {
return HtmlContentViewer( return HtmlContentViewer(
key: controller.htmlContentViewKey,
contentHtml: allEmailContents, contentHtml: allEmailContents,
initialWidth: constraints.maxWidth, initialWidth: constraints.maxWidth,
direction: AppUtils.getCurrentDirection(context), direction: AppUtils.getCurrentDirection(context),
@@ -486,6 +487,7 @@ class EmailView extends GetWidget<SingleEmailController> {
), ),
child: LayoutBuilder(builder: (context, constraints) { child: LayoutBuilder(builder: (context, constraints) {
return HtmlContentViewer( return HtmlContentViewer(
key: controller.htmlContentViewKey,
contentHtml: allEmailContents, contentHtml: allEmailContents,
initialWidth: constraints.maxWidth, initialWidth: constraints.maxWidth,
direction: AppUtils.getCurrentDirection(context), direction: AppUtils.getCurrentDirection(context),