Skip to content

Fix double-escaped quote regex in translation catalogs (Behat 4 BC break) - #64

Open
YoannChabert wants to merge 2 commits into
FriendsOfBehat:masterfrom
short-edition:fix-i18n-escaped-quote-regex
Open

YoannChabert wants to merge 2 commits into
FriendsOfBehat:masterfrom
short-edition:fix-i18n-escaped-quote-regex

Conversation

@YoannChabert

@YoannChabert YoannChabert commented Jul 15, 2026

Copy link
Copy Markdown

Summary

Every non-English translation catalog under i18n/*.xliff (17 files: cs, da, de, es, fr, hu, id, it, ja, nl, pl, pt, pt-br, ro, ru, sk, sv, zh-CN) has a <source> (and, where the same construct appears, <target>) entry that is a byte-for-byte mismatch with the actual regex Behat looks up as a translation key for every step whose pattern contains an escaped-quote alternative such as (?:[^"]|\\")*. In practice this is most of MinkContext's string-argument steps: press, follow, fill in, select, check, uncheck, attach the file, should (not) see, the field should (not) contain, the element should (not) contain, etc.

Root cause

MinkContext's step patterns are declared as PHP single-quoted string literals, e.g.:

#[\Behat\Step\When('/^(?:|I )press "(?P<button>(?:[^"]|\\")*)"$/')]

PHP decodes the \\" escape sequence in a single-quoted string to a single backslash followed by a literal quote before Behat ever sees the string. That decoded string (one backslash and one quote) is what Behat's translator uses as the lookup key (msgid) against each catalog's <source>.

The i18n/*.xliff files are raw XML CDATA, not PHP string literals, so there is no such decoding step for their contents. Every affected <source> still contained the literal two-backslash sequence \\" (i.e. an actual backslash character followed by a quote character, four bytes wide: \, \, "), rather than the one-backslash sequence Behat actually looks for.

Because Symfony's translator does an exact match on the msgid, the lookup silently fails for all of these entries. The practical effect: any step that uses this escaped-quote pattern is never translated in any locale. Writing a # language: xx feature file and using, say, "I press" translated into that language for these steps doesn't work, with no error, just a step definition that doesn't match.

Why this is a Behat 4 BC break, not a long-standing bug

This bug is new-ish and specifically tied to Behat 4 support, not something that's been silently broken since the extension's creation:

  • Before commit c0d7a83 ("Fix CI shell safety, widen Symfony constraints, add Behat 4 step attributes"), MinkContext declared its steps only via @When-style docblock annotations, read via getDocComment() reflection, i.e. raw comment bytes, with no PHP string-escape decoding. The docblock's \\" was byte-identical to the XLIFF <source>'s \\", so translation matching worked fine under Behat 3 (which reads docblocks).
  • That commit added #[\Behat\Step\When(...)] PHP 8 attributes alongside the docblocks because Behat 4 ignores docblock annotations and requires attributes. But inside an attribute, the regex is a real single-quoted PHP string literal, which PHP does decode (\\"\") before Behat ever sees it.
  • Net effect: Behat 3 still reads the docblocks and keeps working (bug masked); Behat 4 reads only the attributes, whose decoded regex no longer matches any \\" XLIFF catalog, so translated steps silently fall back to untranslated matching.

So this is effectively a backwards-compatibility break introduced by Behat 4 support: projects that translate their feature files and upgrade from Behat 3 to Behat 4 (without any other change) will see their translated steps stop matching, with no error, just steps reported as undefined. It's not a pre-existing per-language quirk and not related to symfony/translation itself (the package doesn't even depend on it).

Fix

Strip the redundant backslash so each <source> (and <target>, where it also used the pattern) exactly mirrors the string PHP actually decodes from the corresponding attribute in MinkContext.php. Applied identically across all 17 locale files, this is the same bug repeated in every catalog, not a per-language issue.

Verified byte-for-byte for a sample unit (i-press-button) via ReflectionClass/ReflectionAttribute against the live MinkContext::pressButton step definition (both hex-dumped to 43 identical bytes) rather than by inspection alone.

Test plan

  • Confirmed via hex_dump/ReflectionAttribute comparison that the fixed fr.xliff <source> for i-press-button is now byte-identical to the actual PHP-decoded regex used by MinkContext::pressButton.
  • Confirmed no remaining occurrences of the buggy double-backslash sequence in any of the 17 catalogs.
  • Added features/search.fr.feature, a French translation of the existing search.feature, exercising 4 of the affected steps. Confirmed it reproduces the regression against the pre-fix catalog (6 of 8 steps undefined) and passes fully with the fix (8/8 steps, full suite green against Wikipedia).

MinkContext's step definitions are declared as PHP single-quoted
string attributes, e.g. '/^(?:|I )press "(?P<button>(?:[^"]|\\")*)"$/'.
PHP decodes \\" to a single backslash followed by a quote before
Behat uses that decoded string as the translation msgid.

The i18n/*.xliff catalogs are raw XML CDATA, not PHP string literals,
so they go through no such decoding. Every <source> entry that used
\\" therefore held a different byte sequence (backslash-backslash-quote)
than the actual msgid Behat looks up (backslash-quote), causing an
exact-match failure in the translator for every step whose regex
contains an escaped-quote alternative — the large majority of
MinkContext's string-argument steps (press, follow, fill in, select,
check, uncheck, attach file, should (not) see, field should (not)
contain, etc.).

Because the msgid never matched, Behat silently fell back to
untranslated matching for these steps in every non-English locale,
so translated feature files could not use them at all.

Fix: strip the extra backslash so <source> exactly mirrors the
PHP-decoded regex, and apply the same correction to <target> where
present, across all 17 translation catalogs (all locales but English,
which needs no catalog). Verified byte-for-byte against the actual
attribute string via reflection for the i-press-button unit.
features/search.fr.feature mirrors search.feature but is written
entirely in French (Gherkin keywords via '# language: fr', step
text via the fr.xliff translations), exercising four of the steps
affected by the previous double-escaping bug: "je suis sur",
"je remplis ... avec ...", "je presse", "je devrais voir".

Confirmed the regression this guards against: running this feature
with a --dry-run against the pre-fix fr.xliff reports 6 of 8 steps
as undefined (every affected step except "je suis sur", whose regex
doesn't use the escaped-quote alternative). With the fix, all 8
steps are recognized, and the full suite passes against Wikipedia
the same way the existing English suite does.
@YoannChabert

YoannChabert commented Jul 15, 2026

Copy link
Copy Markdown
Author

Added `features/search.fr.feature` — a French translation of the existing `search.feature`, exercising 4 of the affected steps (`je suis sur`, `je remplis ... avec ...`, `je presse`, `je devrais voir`).

Confirmed the regression this guards against by running it with `--dry-run` against the pre-fix `fr.xliff`:

2 scénarios (2 indéfinis)
8 étapes (6 indéfinis, 2 ignorés)

(`je suis sur` is the one step here whose regex doesn't use the escaped-quote alternative, so it was already matching.)

With the fix, all 8 steps are recognized and the suite passes for real against Wikipedia, the same way the existing English `search.feature` does in CI:

2 scénarios (2 succès)
8 étapes (8 succès)

@aik099

aik099 commented Aug 17, 2026

Copy link
Copy Markdown

Thank you for your contribution @YoannChabert. I've approved this PR.

Any idea why inability to write press button and similar steps in non-English wasn't caught since this Behat extension creation time?

@YoannChabert

YoannChabert commented Aug 24, 2026

Copy link
Copy Markdown
Author

It is directly tied to the Behat 4 migration specifically commit c0d7a83 ("Fix CI shell safety, widen Symfony constraints, add Behat 4 step attributes", 2026-06-12).

Before that commit, steps were declared only via @When-style docblock annotations. That text is extracted from the raw docblock via reflection (getDocComment()) — it's plain comment text, never run through PHP's string-escape decoding. So \\" in the docblock was byte-for-byte identical to \\" in the XLIFF <source>, and translation matching worked.

That commit added #[\Behat\Step\When(...)] attributes alongside the existing docblocks, with the stated reason: "so Behat 4 (which ignores docblock annotations) can discover all step definitions." The catch: inside an attribute, the regex is a real single-quoted PHP string literal, which PHP decodes before Behat ever sees it — \\" becomes \" (one backslash instead of two). The docblock text is untouched.

So depending on which Behat version reads the context:

  • Behat 3 still reads the docblocks, registers the step from the raw (undecoded) regex, which still matches the (unfixed) XLIFF catalogs, translation keeps working, bug stays invisible.
  • Behat 4 ignores docblocks entirely and only discovers steps via attributes, so only the PHP-decoded regex (\") exists, which no longer matches the catalogs' \\" — silent fallback to untranslated matching kicks in.

So this almost certainly sat latent for years while everyone was on Behat 3, and only became an actual, user-facing regression once a project upgraded to Behat 4.

@aik099

aik099 commented Aug 24, 2026

Copy link
Copy Markdown

Ah-ha. Then then it's BC break for Behat 4. Worth noting in the PR title/description and commit message so that release notes stay useful.

@YoannChabert YoannChabert changed the title Fix double-escaped quote regex in translation catalogs Fix double-escaped quote regex in translation catalogs (Behat 4 BC break) Aug 25, 2026
@YoannChabert

Copy link
Copy Markdown
Author

Updated 👍🏼

@mpdude

mpdude commented Sep 1, 2026

Copy link
Copy Markdown

Ran into this issue as well – but I think we need to fix it at the other end, see #65.

The regexes have changed and are now too permissive. We need to fix the regex back to the original values, not make the translations follow the change.

@mpdude mpdude left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #65 – we need to fix the regex values that changed in what they cover; not update the translations to match the changed regexes.

@YoannChabert

Copy link
Copy Markdown
Author

Agreed, #65 is the more fundamental fix, it addresses the actual regex over-permissiveness (which is a real matching bug, not just a translation lookup issue), and as a side effect it requires zero changes to any of the 18 i18n/*.xliff catalogs, since the decoded attribute regex goes back to matching what they already contain.

I'll defer to #65 here, happy to close this PR once #65 is merged, since the catalog changes in this PR would become redundant (and would need reverting) once the regex itself is fixed upstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants