From 56fd93db184d4fa0b82ed94f986d14ee61d5d96c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 1 May 2026 14:28:34 +0100 Subject: [PATCH] fix(regex): honour leading inline flags like (?i) on .NET Regex Strips a leading inline flag group `(?ism-ism)` from the regex source before sending to the driver, and merges those flags with `Regex.Options`. The pattern is otherwise sent verbatim and JS-side regex syntax does not support `(?i)` modifier groups, so they previously failed with "Invalid group". Fixes https://github.com/microsoft/playwright-dotnet/issues/3236 --- src/Playwright.Tests/ExtensionTests.cs | 26 +++++++++++ src/Playwright.Tests/PageRouteTests.cs | 12 +++++ src/Playwright/Core/AssertionsBase.cs | 5 ++- src/Playwright/Core/BrowserContext.cs | 20 +++++---- src/Playwright/Core/Locator.cs | 3 +- src/Playwright/Core/RouteHandler.cs | 5 ++- src/Playwright/Core/WebSocketRouteHandler.cs | 5 ++- .../Helpers/RegexOptionsExtensions.cs | 45 +++++++++++++++++++ .../EvaluateArgumentValueConverter.cs | 3 +- 9 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/Playwright.Tests/ExtensionTests.cs b/src/Playwright.Tests/ExtensionTests.cs index 5359b79946..1b66b71a41 100644 --- a/src/Playwright.Tests/ExtensionTests.cs +++ b/src/Playwright.Tests/ExtensionTests.cs @@ -43,4 +43,30 @@ public void ShouldSerializeRegexpFlagsCorrectly() Assert.AreEqual(new Regex("foo", RegexOptions.IgnorePatternWhitespace).Options.GetInlineFlags(), "ism"); }); } + + [Test] + public void ShouldExtractLeadingInlineFlags() + { + Assert.AreEqual(("foo", ""), new Regex("foo").GetSourceAndFlags()); + Assert.AreEqual((".+\\.css$", "i"), new Regex(@"(?i).+\.css$").GetSourceAndFlags()); + Assert.AreEqual(("bar", "im"), new Regex("(?im)bar").GetSourceAndFlags()); + Assert.AreEqual(("bar", "ism"), new Regex("(?ims)bar").GetSourceAndFlags()); + + // Constructor flags merge with inline flags. + Assert.AreEqual(("bar", "im"), new Regex("(?i)bar", RegexOptions.Multiline).GetSourceAndFlags()); + + // Disable form: (?-i) clears the constructor flag. + Assert.AreEqual(("bar", ""), new Regex("(?-i)bar", RegexOptions.IgnoreCase).GetSourceAndFlags()); + Assert.AreEqual(("bar", "i"), new Regex("(?i-m)bar", RegexOptions.Multiline).GetSourceAndFlags()); + + // Only the leading group is stripped; later groups stay in the source. + Assert.AreEqual(("foo(?m)bar", "i"), new Regex("(?i)foo(?m)bar").GetSourceAndFlags()); + + // Non-modifier groups (e.g., non-capturing) are not touched. + Assert.AreEqual(("(?:foo)", ""), new Regex("(?:foo)").GetSourceAndFlags()); + + // Unsupported inline flags throw. + Assert.Throws(() => new Regex("(?n)foo").GetSourceAndFlags()); + Assert.Throws(() => new Regex("(?x)foo").GetSourceAndFlags()); + } } diff --git a/src/Playwright.Tests/PageRouteTests.cs b/src/Playwright.Tests/PageRouteTests.cs index 2760425005..0fbab8713d 100644 --- a/src/Playwright.Tests/PageRouteTests.cs +++ b/src/Playwright.Tests/PageRouteTests.cs @@ -268,6 +268,18 @@ public async Task ShouldBeAbortable() Assert.AreEqual(1, failedRequests); } + [PlaywrightTest("page-route.spec.ts", "should honour leading inline regex flags")] + public async Task ShouldHonourLeadingInlineRegexFlags() + { + await Page.RouteAsync(new Regex(@"(?i).+\.CSS$"), (route) => route.AbortAsync()); + + int failedRequests = 0; + Page.RequestFailed += (_, _) => ++failedRequests; + var response = await Page.GotoAsync(Server.Prefix + "/one-style.html"); + Assert.True(response.Ok); + Assert.AreEqual(1, failedRequests); + } + [PlaywrightTest("page-route.spec.ts", "should be abortable with custom error codes")] public async Task ShouldBeAbortableWithCustomErrorCodes() { diff --git a/src/Playwright/Core/AssertionsBase.cs b/src/Playwright/Core/AssertionsBase.cs index bfb1a2b9e1..24e3864081 100644 --- a/src/Playwright/Core/AssertionsBase.cs +++ b/src/Playwright/Core/AssertionsBase.cs @@ -101,8 +101,9 @@ protected ExpectedTextValue ExpectedRegex(Regex pattern, ExpectedTextValue? opti } ExpectedTextValue textValue = options ?? new() { }; - textValue.RegexSource = pattern.ToString(); - textValue.RegexFlags = pattern.Options.GetInlineFlags(); + var (source, flags) = pattern.GetSourceAndFlags(); + textValue.RegexSource = source; + textValue.RegexFlags = flags; return textValue; } diff --git a/src/Playwright/Core/BrowserContext.cs b/src/Playwright/Core/BrowserContext.cs index aeb357308a..ea350e8346 100644 --- a/src/Playwright/Core/BrowserContext.cs +++ b/src/Playwright/Core/BrowserContext.cs @@ -332,17 +332,20 @@ public async Task AddInitScriptAsync(string? script = null, st [MethodImpl(MethodImplOptions.NoInlining)] public async Task ClearCookiesAsync(BrowserContextClearCookiesOptions? options = default) { + var nameRegex = options?.NameRegex?.GetSourceAndFlags(); + var domainRegex = options?.DomainRegex?.GetSourceAndFlags(); + var pathRegex = options?.PathRegex?.GetSourceAndFlags(); var @params = new Dictionary { ["name"] = options?.Name ?? options?.NameString, - ["nameRegexSource"] = options?.NameRegex?.ToString(), - ["nameRegexFlags"] = options?.NameRegex?.Options.GetInlineFlags(), + ["nameRegexSource"] = nameRegex?.Source, + ["nameRegexFlags"] = nameRegex?.Flags, ["domain"] = options?.Domain ?? options?.DomainString, - ["domainRegexSource"] = options?.DomainRegex?.ToString(), - ["domainRegexFlags"] = options?.DomainRegex?.Options.GetInlineFlags(), + ["domainRegexSource"] = domainRegex?.Source, + ["domainRegexFlags"] = domainRegex?.Flags, ["path"] = options?.Path ?? options?.PathString, - ["pathRegexSource"] = options?.PathRegex?.ToString(), - ["pathRegexFlags"] = options?.PathRegex?.Options.GetInlineFlags(), + ["pathRegexSource"] = pathRegex?.Source, + ["pathRegexFlags"] = pathRegex?.Flags, }; await SendMessageToServerAsync("clearCookies", @params).ConfigureAwait(false); @@ -913,8 +916,9 @@ internal async Task RecordIntoHarAsync(string har, Page? page, BrowserContextRou } if (options?.UrlRegex != null) { - recordHarArgs["urlRegexSource"] = options?.UrlRegex.ToString(); - recordHarArgs["urlRegexFlags"] = options?.UrlRegex.Options.GetInlineFlags(); + var (source, flags) = options.UrlRegex.GetSourceAndFlags(); + recordHarArgs["urlRegexSource"] = source; + recordHarArgs["urlRegexFlags"] = flags; } recordHarArgs["mode"] = options?.UpdateMode ?? HarMode.Minimal; diff --git a/src/Playwright/Core/Locator.cs b/src/Playwright/Core/Locator.cs index 689581b212..f75cbec1fe 100644 --- a/src/Playwright/Core/Locator.cs +++ b/src/Playwright/Core/Locator.cs @@ -616,8 +616,9 @@ private static string EscapeForAttributeSelector(Regex value, bool exact) private static string EscapeRegexForSelector(Regex text) { + var (source, flags) = text.GetSourceAndFlags(); // Even number of backslashes followed by the quote -> insert a backslash. - return Regex.Replace($"/{text}/{text.Options.GetInlineFlags()}", @"(^|[^\\])(\\\\)*([\""'`])", "$1$2\\$3").Replace(">>", "\\>\\>"); + return Regex.Replace($"/{source}/{flags}", @"(^|[^\\])(\\\\)*([\""'`])", "$1$2\\$3").Replace(">>", "\\>\\>"); } private static string EscapeForTextSelector(Regex text, bool? exact) diff --git a/src/Playwright/Core/RouteHandler.cs b/src/Playwright/Core/RouteHandler.cs index aa6d96a4a3..6540687884 100644 --- a/src/Playwright/Core/RouteHandler.cs +++ b/src/Playwright/Core/RouteHandler.cs @@ -59,8 +59,9 @@ public static List> PrepareInterceptionPatterns(List< } else if (handler.urlMatcher.re != null) { - pattern["regexSource"] = handler.urlMatcher.re.ToString(); - pattern["regexFlags"] = handler.urlMatcher.re.Options.GetInlineFlags(); + var (source, flags) = handler.urlMatcher.re.GetSourceAndFlags(); + pattern["regexSource"] = source; + pattern["regexFlags"] = flags; } if (handler.urlMatcher.func != null) diff --git a/src/Playwright/Core/WebSocketRouteHandler.cs b/src/Playwright/Core/WebSocketRouteHandler.cs index fef5cb8935..b41434cc9c 100644 --- a/src/Playwright/Core/WebSocketRouteHandler.cs +++ b/src/Playwright/Core/WebSocketRouteHandler.cs @@ -49,8 +49,9 @@ public static List> PrepareInterceptionPatterns(List< } else if (handler.urlMatcher.re != null) { - pattern["regexSource"] = handler.urlMatcher.re.ToString(); - pattern["regexFlags"] = handler.urlMatcher.re.Options.GetInlineFlags(); + var (source, flags) = handler.urlMatcher.re.GetSourceAndFlags(); + pattern["regexSource"] = source; + pattern["regexFlags"] = flags; } else { diff --git a/src/Playwright/Helpers/RegexOptionsExtensions.cs b/src/Playwright/Helpers/RegexOptionsExtensions.cs index 0117e6aa3b..3e77ad96f8 100644 --- a/src/Playwright/Helpers/RegexOptionsExtensions.cs +++ b/src/Playwright/Helpers/RegexOptionsExtensions.cs @@ -32,6 +32,51 @@ namespace Microsoft.Playwright.Helpers; /// internal static class RegexOptionsExtensions { + private static readonly Regex LeadingInlineFlags = new(@"^\(\?([imnsx]*)(?:-([imnsx]+))?\)", RegexOptions.Compiled); + + /// + /// Returns the regex pattern (with any leading inline flag group like (?i) stripped) and the + /// combined flag string, merging with the stripped inline flags. + /// + public static (string Source, string Flags) GetSourceAndFlags(this Regex regex) + { + var source = regex.ToString(); + var options = regex.Options; + var match = LeadingInlineFlags.Match(source); + if (match.Success && (match.Groups[1].Length > 0 || match.Groups[2].Success)) + { + ApplyInlineFlags(ref options, match.Groups[1].Value, set: true); + if (match.Groups[2].Success) + { + ApplyInlineFlags(ref options, match.Groups[2].Value, set: false); + } + source = source.Substring(match.Length); + } + return (source, options.GetInlineFlags()); + } + + private static void ApplyInlineFlags(ref RegexOptions options, string flags, bool set) + { + foreach (var c in flags) + { + var bit = c switch + { + 'i' => RegexOptions.IgnoreCase, + 's' => RegexOptions.Singleline, + 'm' => RegexOptions.Multiline, + _ => throw new ArgumentException("Unsupported RegularExpression flags"), + }; + if (set) + { + options |= bit; + } + else + { + options &= ~bit; + } + } + } + public static string GetInlineFlags(this System.Text.RegularExpressions.RegexOptions options) { string flags = string.Empty; diff --git a/src/Playwright/Transport/Converters/EvaluateArgumentValueConverter.cs b/src/Playwright/Transport/Converters/EvaluateArgumentValueConverter.cs index 3cd20570af..c39e77d6a1 100644 --- a/src/Playwright/Transport/Converters/EvaluateArgumentValueConverter.cs +++ b/src/Playwright/Transport/Converters/EvaluateArgumentValueConverter.cs @@ -137,7 +137,8 @@ internal static object Serialize(object? value, List