Skip to content

Commit 3bfd7be

Browse files
hi-ogawacodex
andauthored
fix: fix /children directive on root fragment (take 2) (#19)
Co-authored-by: Codex <noreply@openai.com>
1 parent 2b258fc commit 3bfd7be

4 files changed

Lines changed: 219 additions & 25 deletions

File tree

src/aria/folk/isomorphic/ariaSnapshot.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,12 @@ export class KeyParser {
584584
this._skipWhitespace()
585585

586586
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role']
587+
// DIVERGENCE(playwright):
588+
// we reject explicit `fragment` since that complicates matching algorithm invariant.
589+
// this is only meant for internal role generated to model root templates.
590+
if (role === 'fragment') {
591+
this._throwError('Invalid role "fragment"')
592+
}
587593
this._skipWhitespace()
588594
// DIVERGENCE(playwright): upstream uses `|| ''`, which makes
589595
// `- heading [level=1]` produce name="" instead of name=undefined.

src/aria/match.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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(

src/aria/template.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export function renderAriaTemplate(template: AriaTemplateNode): string {
3030
if (template.kind === 'text') {
3131
lines.push(`- text: ${formatTextValue(template.text)}`)
3232
} else if (template.role === 'fragment') {
33+
if (template.containerMode && template.containerMode !== 'contain') {
34+
lines.push(`- /children: ${template.containerMode}`)
35+
}
3336
for (const child of template.children || [])
3437
renderTemplateLines(child, '', lines)
3538
} else {

test/aria.test.ts

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2537,6 +2537,40 @@ describe('parseAriaTemplate', () => {
25372537
)
25382538
})
25392539

2540+
test('unknown alphabetic role is accepted', () => {
2541+
expect(parseAriaTemplate('- madeuprole')).toMatchInlineSnapshot(`
2542+
{
2543+
"kind": "role",
2544+
"name": undefined,
2545+
"role": "madeuprole",
2546+
}
2547+
`)
2548+
})
2549+
2550+
test('explicit fragment role is rejected', () => {
2551+
expect(() => parseAriaTemplate('- fragment'))
2552+
.toThrowErrorMatchingInlineSnapshot(`
2553+
[Error: Invalid role "fragment":
2554+
2555+
fragment
2556+
^
2557+
]
2558+
`)
2559+
expect(() =>
2560+
parseAriaTemplate(`
2561+
- main:
2562+
- fragment:
2563+
- button
2564+
`)
2565+
).toThrowErrorMatchingInlineSnapshot(`
2566+
[Error: Invalid role "fragment":
2567+
2568+
fragment
2569+
^
2570+
]
2571+
`)
2572+
})
2573+
25402574
// Block scalars (|) are not supported by the minimal YAML parser.
25412575
test('YAML block scalar (| multiline) is not supported', () => {
25422576
expect(() =>
@@ -4444,6 +4478,137 @@ describe('/children directive', () => {
44444478
`)
44454479
})
44464480

4481+
test('/children: equal at root', () => {
4482+
const html = `
4483+
<ul>
4484+
<li>a</li>
4485+
<li>b</li>
4486+
</ul>
4487+
`
4488+
const template = `
4489+
- /children: equal
4490+
- list:
4491+
- listitem: a
4492+
`
4493+
expect(match(html, template)).toMatchInlineSnapshot(`
4494+
{
4495+
"actual": "
4496+
- list:
4497+
- listitem: a
4498+
- listitem: b
4499+
",
4500+
"actualResolved": "
4501+
- /children: equal
4502+
- list:
4503+
- listitem: a
4504+
",
4505+
"expected": "
4506+
- /children: equal
4507+
- list:
4508+
- listitem: a
4509+
",
4510+
"pass": true,
4511+
}
4512+
`)
4513+
})
4514+
4515+
test('/children: deep-equal at root - fail', () => {
4516+
const html = `
4517+
<ul>
4518+
<li>A</li>
4519+
<li>B</li>
4520+
</ul>
4521+
`
4522+
const template = `
4523+
- /children: deep-equal
4524+
- list:
4525+
- listitem: a
4526+
`
4527+
expect(match(html, template)).toMatchInlineSnapshot(`
4528+
{
4529+
"actual": "
4530+
- list:
4531+
- listitem: A
4532+
- listitem: B
4533+
",
4534+
"actualResolved": "
4535+
- list:
4536+
- listitem: A
4537+
- listitem: B
4538+
",
4539+
"expected": "
4540+
- /children: deep-equal
4541+
- list:
4542+
- listitem: a
4543+
",
4544+
"pass": false,
4545+
}
4546+
`)
4547+
})
4548+
4549+
test('/children: deep-equal at root - pass', () => {
4550+
const html = `
4551+
<div></div>
4552+
`
4553+
const template = `
4554+
- /children: deep-equal
4555+
`
4556+
expect(match(html, template)).toMatchInlineSnapshot(`
4557+
{
4558+
"actual": "
4559+
4560+
",
4561+
"actualResolved": "
4562+
- /children: deep-equal
4563+
",
4564+
"expected": "
4565+
- /children: deep-equal
4566+
",
4567+
"pass": true,
4568+
}
4569+
`)
4570+
})
4571+
4572+
test('/children: equal at non root', () => {
4573+
const html = `
4574+
<main>
4575+
<ul>
4576+
<li>a</li>
4577+
<li>b</li>
4578+
</ul>
4579+
</main>
4580+
`
4581+
const template = `
4582+
- main:
4583+
- /children: equal
4584+
- list:
4585+
- listitem: a
4586+
`
4587+
expect(match(html, template)).toMatchInlineSnapshot(`
4588+
{
4589+
"actual": "
4590+
- main:
4591+
- list:
4592+
- listitem: a
4593+
- listitem: b
4594+
",
4595+
"actualResolved": "
4596+
- main:
4597+
- /children: equal
4598+
- list:
4599+
- listitem: a
4600+
",
4601+
"expected": "
4602+
- main:
4603+
- /children: equal
4604+
- list:
4605+
- listitem: a
4606+
",
4607+
"pass": true,
4608+
}
4609+
`)
4610+
})
4611+
44474612
test('/children: equal — resolved preserves directive on matched branch, purges on failed', () => {
44484613
// Two sibling lists both with /children: equal.
44494614
// First list matches exactly → directive preserved in resolved.
@@ -4546,12 +4711,15 @@ describe('/children directive', () => {
45464711

45474712
test('renderAriaTemplate preserves /children directive', () => {
45484713
const t = parseAriaTemplate(`
4714+
- /children: equal
45494715
- list:
45504716
- /children: equal
45514717
- listitem: A
45524718
`)
4553-
expect(renderAriaTemplate(t)).toMatchInlineSnapshot(`
4554-
"- list:
4719+
expect('\n' + renderAriaTemplate(t)).toMatchInlineSnapshot(`
4720+
"
4721+
- /children: equal
4722+
- list:
45554723
- /children: equal
45564724
- listitem: A"
45574725
`)

0 commit comments

Comments
 (0)