Skip to content

Commit 27b1597

Browse files
authored
Merge f31ecf3 into a18fdb2
2 parents a18fdb2 + f31ecf3 commit 27b1597

8 files changed

Lines changed: 708 additions & 12 deletions

File tree

Rnwood.Smtp4dev.Tests/E2E/E2ETests_DarkModeRendering.cs

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.

Rnwood.Smtp4dev.Tests/E2E/PageModel/HomePage.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,37 @@ public async Task OpenSettingsAsync()
3737

3838
public MessageView MessageView => new MessageView(page);
3939

40+
public ILocator GetDarkModeToggleButton()
41+
{
42+
// The dark mode toggle is the first circular button in the header
43+
// Since it's the only icon-only circular button in the header after the logo
44+
return page.Locator("header button.el-button.is-circle").First;
45+
}
46+
47+
public async Task ToggleDarkModeAsync()
48+
{
49+
var darkModeButton = GetDarkModeToggleButton();
50+
await darkModeButton.ClickAsync();
51+
}
52+
53+
public async Task<bool> IsDarkModeActiveAsync()
54+
{
55+
// Check if the html element has the 'dark' class
56+
var htmlElement = page.Locator("html");
57+
var classes = await htmlElement.GetAttributeAsync("class");
58+
return classes?.Contains("dark") == true;
59+
}
60+
61+
public async Task SetDarkModeAsync(bool isDarkMode)
62+
{
63+
bool currentDarkMode = await IsDarkModeActiveAsync();
64+
if (currentDarkMode != isDarkMode)
65+
{
66+
await ToggleDarkModeAsync();
67+
await page.WaitForTimeoutAsync(1000); // Allow UI to update
68+
}
69+
}
70+
4071
public class MessageListControl
4172
{
4273
private readonly ILocator element;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<RunSettings>
3+
<RunConfiguration>
4+
<EnvironmentVariables>
5+
<!-- Playwright configuration for HTML reports -->
6+
<PLAYWRIGHT_HTML_REPORT>$(Agent.TempDirectory)/playwright-report</PLAYWRIGHT_HTML_REPORT>
7+
<PLAYWRIGHT_HTML_OPEN>never</PLAYWRIGHT_HTML_OPEN>
8+
</EnvironmentVariables>
9+
</RunConfiguration>
10+
</RunSettings>

Rnwood.Smtp4dev/ClientApp/package-lock.json

Lines changed: 38 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Rnwood.Smtp4dev/ClientApp/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@
6868
"not ie 11"
6969
],
7070
"dependencies": {
71+
"@types/css-tree": "^2.3.10",
7172
"@vueuse/components": "^13.0.0",
73+
"css-tree": "^3.1.0",
7274
"patch-package": "^8.0.0",
7375
"util": "^0.12.5"
7476
}

Rnwood.Smtp4dev/ClientApp/src/components/messageviewhtml.vue

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
</el-alert>
4242

4343
<div class="fill" style="display: flex; flex-direction: column;">
44-
<iframe class="htmlview" :style="htmlFrameStyles" @load="onHtmlFrameLoaded" ref="htmlframe"></iframe>
44+
<iframe :class="htmlFrameClasses" :style="htmlFrameStyles" @load="onHtmlFrameLoaded" ref="htmlframe"></iframe>
4545
</div>
4646
</div>
4747
</template>
@@ -54,6 +54,7 @@
5454
import * as srcDoc from 'srcdoc-polyfill';
5555
import sanitizeHtml from 'sanitize-html';
5656
import { deviceSizes, Brand } from 'device-sizes'
57+
import * as csstree from 'css-tree';
5758
5859
type ViewPortSize = {
5960
name: string
@@ -79,6 +80,7 @@
7980
enableSanitization = true;
8081
sanitizedHtml: string | null = null;
8182
wasSanitized: boolean = false;
83+
emailSupportsDarkMode: boolean = false;
8284
8385
availableViewportSizes: ViewPortSize[] = [{ name: "Normal", fill: true }].concat(Object.values(deviceSizes).map(d => ({
8486
name: `${Brand[d.brand]} ${d.name} (${d.size}")`, fill: false, width: d.width / d.scale, height: d.height / d.scale
@@ -113,6 +115,13 @@
113115
};
114116
}
115117
118+
get htmlFrameClasses() {
119+
return {
120+
'htmlview': true,
121+
'supports-dark-mode': this.emailSupportsDarkMode
122+
};
123+
}
124+
116125
117126
error: Error | null = null;
118127
loading = false;
@@ -134,20 +143,161 @@
134143
private updateIframe() {
135144
this.wasSanitized = false;
136145
this.sanitizedHtml = "";
146+
this.emailSupportsDarkMode = false; // Reset first
137147
138148
if (this.html) {
149+
// Check if email supports dark mode before sanitization
150+
const originalDarkModeSupport = this.detectDarkModeSupport(this.html);
151+
console.log('Dark mode detection on original HTML:', originalDarkModeSupport, 'for HTML length:', this.html.length);
152+
139153
if (!this.enableSanitization) {
140154
this.sanitizedHtml = this.html;
155+
this.emailSupportsDarkMode = originalDarkModeSupport;
141156
} else {
142-
this.sanitizedHtml = sanitizeHtml(this.html, { allowedTags: sanitizeHtml.defaults.allowedTags.concat("img"), allowedSchemesByTag: { "img": ["cid", "data"] } });
157+
// Allow additional tags and attributes needed for dark mode detection
158+
const sanitizeOptions = {
159+
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
160+
"img", "style", "meta", "head", "html", "body"
161+
]),
162+
allowedAttributes: {
163+
...sanitizeHtml.defaults.allowedAttributes,
164+
"meta": ["name", "content", "charset", "http-equiv"],
165+
"html": ["lang", "dir"],
166+
"body": ["class"],
167+
"*": ["style", "class", "id"] // Allow style, class, and id on all elements for better CSS support
168+
},
169+
allowedSchemesByTag: {
170+
"img": ["cid", "data"]
171+
}
172+
};
173+
174+
this.sanitizedHtml = sanitizeHtml(this.html, sanitizeOptions);
143175
let normalizedOriginalHtml = sanitizeHtml(this.html, { allowedAttributes: false, allowedTags: false, allowVulnerableTags: true });
144176
this.wasSanitized = normalizedOriginalHtml !== this.sanitizedHtml;
177+
178+
// Check dark mode support on sanitized HTML
179+
this.emailSupportsDarkMode = this.detectDarkModeSupport(this.sanitizedHtml);
180+
console.log('Dark mode detection on sanitized HTML:', this.emailSupportsDarkMode, 'for sanitized HTML length:', this.sanitizedHtml.length);
181+
182+
if (originalDarkModeSupport !== this.emailSupportsDarkMode) {
183+
console.warn('⚠️ Dark mode detection result changed after sanitization!',
184+
'Original:', originalDarkModeSupport, 'Sanitized:', this.emailSupportsDarkMode);
185+
}
145186
}
146187
}
147188
148189
srcDoc.set(this.$refs.htmlframe as HTMLIFrameElement, this.sanitizedHtml);
149190
}
150191
192+
private detectDarkModeSupport(html: string): boolean {
193+
try {
194+
console.log('Detecting dark mode support in HTML...');
195+
196+
// Use DOMParser to safely parse HTML
197+
const parser = new DOMParser();
198+
const doc = parser.parseFromString(html, 'text/html');
199+
200+
// Check for supported-color-schemes meta tag
201+
const supportedColorSchemesMeta = doc.querySelector('meta[name="supported-color-schemes"]');
202+
if (supportedColorSchemesMeta) {
203+
const content = supportedColorSchemesMeta.getAttribute('content') || '';
204+
console.log('Found supported-color-schemes meta tag with content:', content);
205+
if (this.parseColorSchemeValues(content).includes('dark')) {
206+
console.log('✅ Dark mode detected via supported-color-schemes meta tag');
207+
return true;
208+
}
209+
}
210+
211+
// Check for color-scheme meta tag
212+
const colorSchemeMeta = doc.querySelector('meta[name="color-scheme"]');
213+
if (colorSchemeMeta) {
214+
const content = colorSchemeMeta.getAttribute('content') || '';
215+
console.log('Found color-scheme meta tag with content:', content);
216+
if (this.parseColorSchemeValues(content).includes('dark')) {
217+
console.log('✅ Dark mode detected via color-scheme meta tag');
218+
return true;
219+
}
220+
}
221+
222+
// Check for CSS media queries that indicate dark mode support
223+
const styleElements = doc.querySelectorAll('style');
224+
console.log('Found', styleElements.length, 'style elements');
225+
for (const styleElement of styleElements) {
226+
const cssText = styleElement.textContent || '';
227+
if (cssText && this.parseCSSForDarkModeQueries(cssText)) {
228+
console.log('✅ Dark mode detected via CSS media query');
229+
return true;
230+
}
231+
}
232+
233+
// Also check for dark mode media queries in linked stylesheets or inline styles
234+
// Note: We can't access external stylesheets due to CORS, but we can check style attributes
235+
const elementsWithStyle = doc.querySelectorAll('[style]');
236+
for (const element of elementsWithStyle) {
237+
const styleAttr = element.getAttribute('style') || '';
238+
if (this.parseCSSForDarkModeQueries(styleAttr)) {
239+
console.log('✅ Dark mode detected via inline style');
240+
return true;
241+
}
242+
}
243+
244+
console.log('❌ No dark mode support detected');
245+
return false;
246+
} catch (error) {
247+
console.warn('Error parsing HTML for dark mode detection:', error);
248+
return false;
249+
}
250+
}
251+
252+
private parseColorSchemeValues(content: string): string[] {
253+
if (!content) return [];
254+
255+
// Split by both spaces and commas, trim each value, and filter out empty values
256+
return content
257+
.split(/[\s,]+/)
258+
.map(value => value.trim().toLowerCase())
259+
.filter(value => value.length > 0);
260+
}
261+
262+
private parseCSSForDarkModeQueries(cssText: string): boolean {
263+
if (!cssText) return false;
264+
265+
// First try simple string search as it's more reliable for this use case
266+
const simpleCheck = cssText.toLowerCase().includes('prefers-color-scheme') && cssText.toLowerCase().includes('dark');
267+
if (simpleCheck) {
268+
console.log('✅ Found dark mode media query via simple string search');
269+
return true;
270+
}
271+
272+
try {
273+
// Try css-tree parsing as a secondary check
274+
const ast = csstree.parse(cssText, { parseRulePrelude: false });
275+
276+
let foundDarkModeQuery = false;
277+
278+
csstree.walk(ast, function(node) {
279+
if (node.type === 'Atrule' && node.name === 'media') {
280+
const mediaQueryText = csstree.generate(node.prelude);
281+
console.log('Found @media rule:', mediaQueryText);
282+
if (mediaQueryText.includes('prefers-color-scheme') && mediaQueryText.includes('dark')) {
283+
console.log('✅ Found dark mode media query via css-tree:', mediaQueryText);
284+
foundDarkModeQuery = true;
285+
}
286+
}
287+
});
288+
289+
return foundDarkModeQuery;
290+
} catch (error) {
291+
console.warn('Error parsing CSS with css-tree, using fallback:', error);
292+
return simpleCheck;
293+
}
294+
}
295+
296+
private checkMediaQueryForDarkMode(mediaQuery: any): boolean {
297+
// This method is no longer used, keeping for compatibility
298+
return false;
299+
}
300+
151301
async onHtmlFrameLoaded() {
152302
var doc = (this.$refs.htmlframe as HTMLIFrameElement).contentDocument;
153303
if (!doc) {

Rnwood.Smtp4dev/ClientApp/src/css/site.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ html.dark .textview {
1616
filter: invert();
1717
}
1818

19-
html.dark .htmlview, html.dark .plaintextview {
19+
html.dark .htmlview:not(.supports-dark-mode), html.dark .plaintextview {
2020
filter: invert();
2121
}
2222

azure-pipelines.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,20 @@ stages:
266266
env:
267267
SMTP4DEV_E2E_WORKINGDIR: $(Agent.TempDirectory)/e2e
268268
SMTP4DEV_E2E_BINARY: $(Agent.TempDirectory)/e2e/Rnwood.Smtp4dev
269+
PLAYWRIGHT_HTML_REPORT: $(Agent.TempDirectory)/playwright-report
269270
inputs:
270271
command: test
271272
projects: Rnwood.Smtp4dev.Tests
272273
configuration: release
273274
arguments: '--collect:"XPlat Code Coverage"'
274275
publishTestResults: true
276+
- task: PublishPipelineArtifact@1
277+
condition: and(always(), eq(variables['runTests'], true))
278+
displayName: Publish Playwright HTML Report
279+
inputs:
280+
targetPath: '$(Agent.TempDirectory)/playwright-report'
281+
artifact: 'PlaywrightReport_$(platformName)'
282+
publishLocation: 'pipeline'
275283
- task: DotNetCoreCLI@2
276284
condition: and(succeeded(), eq(variables['runTests'], true))
277285
displayName: Install ReportGenerator Tool

0 commit comments

Comments
 (0)