@@ -36,19 +36,16 @@ import { formatTextValue, formatNameValue } from './template'
3636// captureAriaTree always returns a fragment root; parseAriaTemplate may
3737// or may not (it unwraps single-child fragments, following Playwright).
3838//
39- // We normalize by flattening: fragment = its children. This happens
40- // inside mergeChildLists so every recursion level handles it uniformly.
39+ // We normalize these synthetic root wrappers in matchAriaTree before
40+ // entering the recursive merge. User-authored `fragment` template roles are
41+ // rejected by the parser, so mergeChildLists never needs general fragment
42+ // semantics.
4143//
4244// Playwright takes a different approach: instead of flattening, it treats
4345// fragment as a wildcard role (matches any node) and relies on the entry
44- // point to walk root.children directly. Both are equally sound because
45- // fragment nodes never carry meaningful attributes or containerMode in
46- // practice — the parser only sets containerMode on top-level fragments,
47- // then unwraps single-child ones, so surviving fragments always have
48- // multiple children whose list semantics our flatten preserves correctly.
49- // The tradeoff is decomposition: Playwright keeps all match semantics in
50- // matchesNode; we split fragment handling into mergeChildLists, which is
51- // natural since we need that function anyway for three-way merge.
46+ // point to walk root.children directly. Our root normalization follows the
47+ // same boundary idea while keeping three-way merge decisions in
48+ // mergeChildLists.
5249// See: vendor/playwright/packages/injected/src/ariaSnapshot.ts
5350// (matchesNode, matchesNodeDeep)
5451//
@@ -115,19 +112,38 @@ export function matchAriaTree(
115112 root : AriaNode ,
116113 template : AriaTemplateNode
117114) : MatchAriaResult {
118- // Enter through mergeChildLists — this is intentional, not just for
119- // fragment normalization. mergeChildLists is the only function that
120- // decides pass/fail (via matchesNode); mergeNode below it is purely
121- // rendering. Wrapping in single-element lists ensures that contract
122- // holds from the top level down.
123- const result = mergeChildLists ( [ root ] , [ template ] , '' )
115+ // Normalize synthetic root fragments before entering the recursive merge.
116+ // The template root fragment may own /children metadata, so lift its
117+ // containerMode before unwrapping it into template roots.
118+ const normalizedRoot = root . role === 'fragment' ? root . children : [ root ]
119+ const normalizedTemplate = isAriaTemplateFragement ( template )
120+ ? ( template . children ?? [ ] )
121+ : [ template ]
122+ const containerMode = isAriaTemplateFragement ( template )
123+ ? template . containerMode
124+ : undefined
125+ const result = mergeChildLists (
126+ normalizedRoot ,
127+ normalizedTemplate ,
128+ '' ,
129+ containerMode
130+ )
131+ if ( result . pass && containerMode && containerMode !== 'contain' ) {
132+ result . resolved . unshift ( `- /children: ${ containerMode } ` )
133+ }
124134
125135 return {
126136 pass : result . pass ,
127137 resolved : result . resolved . join ( '\n' ) ,
128138 }
129139}
130140
141+ function isAriaTemplateFragement (
142+ template : AriaTemplateNode
143+ ) : template is AriaTemplateRoleNode {
144+ return template . kind === 'role' && template . role === 'fragment'
145+ }
146+
131147// ---------------------------------------------------------------------------
132148// Merge internals
133149// ---------------------------------------------------------------------------
@@ -244,13 +260,14 @@ function mergeChildLists(
244260 indent : string ,
245261 containerMode ?: ContainerMode
246262) : MergeResult {
247- // fragment = its children (a fragment has no semantics of its own)
248- children = children . flatMap ( ( c ) =>
249- typeof c !== 'string' && c . role === 'fragment' ? c . children : [ c ]
250- )
251- templates = templates . flatMap ( ( t ) =>
252- t . kind === 'role' && t . role === 'fragment' ? t . children || [ ] : [ t ]
253- )
263+ if (
264+ children . some ( ( c ) => typeof c !== 'string' && c . role === 'fragment' ) ||
265+ templates . some ( ( t ) => isAriaTemplateFragement ( t ) )
266+ ) {
267+ throw new Error (
268+ 'Internal error: fragment wrappers must be unwrapped at matchAriaTree root'
269+ )
270+ }
254271
255272 if ( containerMode === 'equal' || containerMode === 'deep-equal' ) {
256273 return mergeChildListsEqual (
0 commit comments