From bd8669b3f988a3509b7083b945549964784fc215 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 09:06:04 +0900 Subject: [PATCH 01/15] test: record current /children directive behavior Add tests documenting that ivya's merge layer ignores containerMode (equal/deep-equal) and always uses contain semantics. Also captures that renderAriaTemplate drops the /children directive on round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/aria.test.ts | 141 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/test/aria.test.ts b/test/aria.test.ts index cd25143..00f6ec5 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -4184,3 +4184,144 @@ describe('aria-expanded', () => { `) }) }) + +// --------------------------------------------------------------------------- +// /children directive +// --------------------------------------------------------------------------- + +describe('/children directive', () => { + // Currently ivya's merge always uses contain semantics regardless of + // containerMode. These tests record the current behavior so we notice + // when support is added. + + test('/children: equal — extra children are NOT rejected (contain semantics)', () => { + const html = ` + + ` + // Template says equal (only A and C), but actual has A, B, C. + // With true equal semantics this should fail; currently it passes + // because merge ignores containerMode. + expect( + match(html, ` + - list: + - /children: equal + - listitem: A + - listitem: C + `, { assertInvariant: false }) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "actualResolved": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "expected": " + - list: + - listitem: A + - listitem: C + ", + "pass": false, + } + `) + }) + + test('/children: deep-equal — extra children are NOT rejected (contain semantics)', () => { + const html = ` + + ` + expect( + match(html, ` + - list: + - /children: deep-equal + - listitem: A + - listitem: C + `, { assertInvariant: false }) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "actualResolved": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "expected": " + - list: + - listitem: A + - listitem: C + ", + "pass": false, + } + `) + }) + + test('/children: contain — behaves the same as default', () => { + const html = ` + + ` + expect( + match(html, ` + - list: + - /children: contain + - listitem: A + - listitem: C + `, { assertInvariant: false }) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "actualResolved": " + - list: + - listitem: A + - listitem: C + ", + "expected": " + - list: + - listitem: A + - listitem: C + ", + "pass": true, + } + `) + }) + + test('renderAriaTemplate drops /children directive', () => { + const t = parseAriaTemplate(` + - list: + - /children: equal + - listitem: A + `) + expect(renderAriaTemplate(t)).toMatchInlineSnapshot(` + "- list: + - listitem: A" + `) + }) +}) From fa326033ccfbfd27e561a5113da2447499d210d9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 09:10:38 +0900 Subject: [PATCH 02/15] feat: render /children directive in renderAriaTemplate Emit `/children: equal` and `/children: deep-equal` when serializing templates. The default `contain` mode is omitted. Pseudo-prop lines are now rendered before children to match parse order. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/aria/template.ts | 4 +++- test/aria.test.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/aria/template.ts b/src/aria/template.ts index 9f23437..df1d0ce 100644 --- a/src/aria/template.ts +++ b/src/aria/template.ts @@ -59,6 +59,8 @@ function renderTemplateLines( const children = node.children || [] const pseudoLines: string[] = [] + if (node.containerMode && node.containerMode !== 'contain') + pseudoLines.push(`${indent} - /children: ${node.containerMode}`) if (node.props) { for (const [name, tv] of Object.entries(node.props)) pseudoLines.push(`${indent} - /${name}: ${formatTextValue(tv)}`) @@ -77,6 +79,6 @@ function renderTemplateLines( return } lines.push(`${indent}- ${key}:`) - for (const child of children) renderTemplateLines(child, `${indent} `, lines) lines.push(...pseudoLines) + for (const child of children) renderTemplateLines(child, `${indent} `, lines) } diff --git a/test/aria.test.ts b/test/aria.test.ts index 00f6ec5..830a075 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -3983,8 +3983,8 @@ describe('matchAriaTree', () => { ", "expected": " - link: - - text: Click here - /url: /.*example.com/ + - text: Click here ", "pass": false, } @@ -4016,8 +4016,8 @@ describe('matchAriaTree', () => { ", "expected": " - link: - - text: Wrong text - /url: /.*example.com/ + - text: Wrong text ", "pass": false, } @@ -4228,6 +4228,7 @@ describe('/children directive', () => { ", "expected": " - list: + - /children: equal - listitem: A - listitem: C ", @@ -4267,6 +4268,7 @@ describe('/children directive', () => { ", "expected": " - list: + - /children: deep-equal - listitem: A - listitem: C ", @@ -4313,7 +4315,7 @@ describe('/children directive', () => { `) }) - test('renderAriaTemplate drops /children directive', () => { + test('renderAriaTemplate preserves /children directive', () => { const t = parseAriaTemplate(` - list: - /children: equal @@ -4321,6 +4323,7 @@ describe('/children directive', () => { `) expect(renderAriaTemplate(t)).toMatchInlineSnapshot(` "- list: + - /children: equal - listitem: A" `) }) From d1b9f565e6e3134142ffe3fba97309bc43a47c35 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 09:15:53 +0900 Subject: [PATCH 03/15] chore: lint --- test/aria.test.ts | 24 ++++++++++++++++++------ vitest.ci.config.ts | 11 +++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/test/aria.test.ts b/test/aria.test.ts index 830a075..aa14585 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -4206,12 +4206,16 @@ describe('/children directive', () => { // With true equal semantics this should fail; currently it passes // because merge ignores containerMode. expect( - match(html, ` + match( + html, + ` - list: - /children: equal - listitem: A - listitem: C - `, { assertInvariant: false }) + `, + { assertInvariant: false } + ) ).toMatchInlineSnapshot(` { "actual": " @@ -4246,12 +4250,16 @@ describe('/children directive', () => { ` expect( - match(html, ` + match( + html, + ` - list: - /children: deep-equal - listitem: A - listitem: C - `, { assertInvariant: false }) + `, + { assertInvariant: false } + ) ).toMatchInlineSnapshot(` { "actual": " @@ -4286,12 +4294,16 @@ describe('/children directive', () => { ` expect( - match(html, ` + match( + html, + ` - list: - /children: contain - listitem: A - listitem: C - `, { assertInvariant: false }) + `, + { assertInvariant: false } + ) ).toMatchInlineSnapshot(` { "actual": " diff --git a/vitest.ci.config.ts b/vitest.ci.config.ts index 53027ca..5c5f489 100644 --- a/vitest.ci.config.ts +++ b/vitest.ci.config.ts @@ -1,15 +1,10 @@ -import { defineConfig } from 'vitest/config' -import { playwright } from '@vitest/browser-playwright' +import { mergeConfig } from 'vitest/config' +import baseConfig from './vitest.config' -export default defineConfig({ +export default mergeConfig(baseConfig, { test: { browser: { - enabled: true, - provider: playwright(), instances: [ - { - browser: 'chromium', - }, { browser: 'firefox', }, From 7a0cf527ff9937cce5ecf36e4b9f4895ce6a2fab Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 09:50:27 +0900 Subject: [PATCH 04/15] fix: support /children: equal --- src/aria/folk/isomorphic/ariaSnapshot.ts | 14 +++++- src/aria/match.ts | 55 +++++++++++++++++++++--- test/aria.test.ts | 54 ++++++++++++++++++----- 3 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/aria/folk/isomorphic/ariaSnapshot.ts b/src/aria/folk/isomorphic/ariaSnapshot.ts index 6563dab..02649de 100644 --- a/src/aria/folk/isomorphic/ariaSnapshot.ts +++ b/src/aria/folk/isomorphic/ariaSnapshot.ts @@ -179,9 +179,11 @@ export type AriaTemplateRoleNode = AriaProps & { name?: AriaRegex | string children?: AriaTemplateNode[] props?: Record - containerMode?: 'contain' | 'equal' | 'deep-equal' + containerMode?: ContainerMode } +export type ContainerMode = 'contain' | 'equal' | 'deep-equal' + export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode import type * as yamlTypes from 'yaml' @@ -731,6 +733,16 @@ export function cachedRegex(template: AriaTextValue): RegExp | null { return regex } +/** + * Full-depth recursive match: checks the node, its attributes, and all + * descendants against the template. The `isDeepEqual` parameter does NOT + * control recursion depth — matching is always full-depth. It only + * controls the *child list mode* when `template.containerMode` is unset: + * - `false` → default contain semantics (template children are a subsequence) + * - `true` → inherited deep-equal (positional 1:1, propagated to descendants) + * When `template.containerMode` is explicitly set (e.g. via `/children: equal`), + * it takes precedence over `isDeepEqual`. + */ export function matchesNode( node: AriaNode | string, template: AriaTemplateNode, diff --git a/src/aria/match.ts b/src/aria/match.ts index 43da692..80ceab7 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -8,6 +8,7 @@ import { type AriaRegex, type AriaTemplateNode, type AriaTemplateRoleNode, + type ContainerMode, } from './folk/isomorphic/ariaSnapshot' import { formatTextValue, formatNameValue } from './template' @@ -239,7 +240,8 @@ function renderChildLines(child: AriaNode | string, indent: string): string[] { function mergeChildLists( children: (AriaNode | string)[], templates: AriaTemplateNode[], - indent: string + indent: string, + containerMode?: ContainerMode ): MergeLines { // fragment = its children (a fragment has no semantics of its own) children = children.flatMap((c) => @@ -249,6 +251,10 @@ function mergeChildLists( t.kind === 'role' && t.role === 'fragment' ? t.children || [] : [t] ) + if (containerMode === 'equal') { + return mergeChildListsEqual(children, templates, indent) + } + const resolved: string[] = [] // we allow matching as subset so it can pass with @@ -290,6 +296,31 @@ function mergeChildLists( return { resolved, pass: true } } +/** Equal mode: positional 1:1 matching. Pass iff same count and every + * positional pair matches (full-depth). */ +function mergeChildListsEqual( + children: (AriaNode | string)[], + templates: AriaTemplateNode[], + indent: string +): MergeLines { + const resolved: string[] = [] + + const allPositionalMatched = + children.length === templates.length && + children.every((c, i) => matchesNode(c, templates[i], false)) + + for (let ci = 0; ci < children.length; ci++) { + if (ci < templates.length) { + const r = mergeNode(children[ci], templates[ci], indent) + resolved.push(...r.resolved) + } else { + resolved.push(...renderChildLines(children[ci], indent)) + } + } + + return { resolved, pass: allPositionalMatched } +} + function mergeNode( node: AriaNode | string, template: AriaTemplateNode, @@ -325,12 +356,15 @@ function mergeNode( // Recurse into children — if template omits children, the lens says // "don't care", so we skip (don't render children in resolved output). const childResult = template.children - ? mergeChildLists(node.children, template.children, `${indent} `) + ? mergeChildLists( + node.children, + template.children, + `${indent} `, + template.containerMode + ) : { resolved: [] as string[], pass: true } - // Build pseudo-child lines for props const resolvedPseudo: string[] = [] - const allPropKeys = new Set([ ...Object.keys(node.props), ...Object.keys(template.props || {}), @@ -371,13 +405,20 @@ function mergeNode( const resolved: string[] = [] - if (!childResult.resolved.length && !resolvedPseudo.length) { + const resolvedDirective: string[] = [] + if (template.containerMode && template.containerMode !== 'contain') { + resolvedDirective.push(`${indent} - /children: ${template.containerMode}`) + } + + const hasExtra = resolvedDirective.length + resolvedPseudo.length > 0 + + if (!childResult.resolved.length && !hasExtra) { // one liner node with no props, e.g. `- role "name" [props]` resolved.push(`${indent}- ${resolvedKey}`) } else if ( childResult.resolved.length === 1 && childResult.resolved[0].trimStart().startsWith('- text: ') && - !resolvedPseudo.length + !hasExtra ) { // one liner node with text child, e.g. `- role "name" [props]: text` const text = childResult.resolved[0].trimStart().slice('- text: '.length) @@ -385,9 +426,11 @@ function mergeNode( } else { // multi-line node with children and/or props, e.g. // - role "name" [props]: + // - /children: equal // - child // - /prop: value resolved.push(`${indent}- ${resolvedKey}:`) + resolved.push(...resolvedDirective) resolved.push(...childResult.resolved) resolved.push(...resolvedPseudo) } diff --git a/test/aria.test.ts b/test/aria.test.ts index aa14585..02eadc7 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -4190,11 +4190,48 @@ describe('aria-expanded', () => { // --------------------------------------------------------------------------- describe('/children directive', () => { - // Currently ivya's merge always uses contain semantics regardless of - // containerMode. These tests record the current behavior so we notice - // when support is added. + test('/children: equal — exact match passes', () => { + const html = ` +
    +
  • A
  • +
  • B
  • +
+ ` + expect( + match( + html, + ` + - list: + - /children: equal + - listitem: A + - listitem: B + ` + ) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: A + - listitem: B + ", + "actualResolved": " + - list: + - /children: equal + - listitem: A + - listitem: B + ", + "expected": " + - list: + - /children: equal + - listitem: A + - listitem: B + ", + "pass": true, + } + `) + }) - test('/children: equal — extra children are NOT rejected (contain semantics)', () => { + test('/children: equal — extra children rejected', () => { const html = `
  • A
  • @@ -4202,9 +4239,6 @@ describe('/children directive', () => {
  • C
` - // Template says equal (only A and C), but actual has A, B, C. - // With true equal semantics this should fail; currently it passes - // because merge ignores containerMode. expect( match( html, @@ -4213,8 +4247,7 @@ describe('/children directive', () => { - /children: equal - listitem: A - listitem: C - `, - { assertInvariant: false } + ` ) ).toMatchInlineSnapshot(` { @@ -4241,7 +4274,7 @@ describe('/children directive', () => { `) }) - test('/children: deep-equal — extra children are NOT rejected (contain semantics)', () => { + test('/children: deep-equal — extra children rejected (not yet deep)', () => { const html = `
  • A
  • @@ -4249,6 +4282,7 @@ describe('/children directive', () => {
  • C
` + // deep-equal is not yet implemented; currently falls back to contain. expect( match( html, From a66a837d851ad0205f74932836bf1942be43d309 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:04:33 +0900 Subject: [PATCH 05/15] refactor: wow, mergeNode doesn't need to return pass/fail --- src/aria/match.ts | 113 +++++++++++++++++++++++----------------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index 80ceab7..c9cfefe 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -15,9 +15,20 @@ import { formatTextValue, formatNameValue } from './template' // --------------------------------------------------------------------------- // matchAriaTree — three-way merge matching (vitest-specific) // -// Uses folk's matchesNode for boolean matching and folk's renderNodeLines -// for actual-side rendering. Only the merge assembly and template rendering -// (which Playwright doesn't need) are implemented here. +// Architecture — separation of pass/fail and rendering: +// pass/fail is decided exclusively at the *list* level (mergeChildLists) +// using folk's matchesNode, which is a full-depth recursive boolean check. +// mergeNode is purely a rendering function — it builds the resolved output +// through the template's lens but has no say in pass/fail (returns string[], +// not a boolean). This separation works because matchesNode already +// traverses the full subtree; re-checking at the node level would be +// redundant. The entry point (matchAriaTree) wraps root and template in +// single-element lists so that mergeChildLists — and its pass/fail +// authority — is always the top-level driver. +// +// folk's renderNodeLines is used for actual-side rendering. Only the merge +// assembly and template rendering (which Playwright doesn't need) are +// implemented here. // // Fragment semantics: // A fragment node has no semantics of its own — it exists only because @@ -119,7 +130,11 @@ export function matchAriaTree( root: AriaNode, template: AriaTemplateNode ): MatchAriaResult { - // recurse as lists to normalize top-level fragments + // Enter through mergeChildLists — this is intentional, not just for + // fragment normalization. mergeChildLists is the only function that + // decides pass/fail (via matchesNode); mergeNode below it is purely + // rendering. Wrapping in single-element lists ensures that contract + // holds from the top level down. const result = mergeChildLists([root], [template], '') return { @@ -132,11 +147,16 @@ export function matchAriaTree( // Merge internals // --------------------------------------------------------------------------- -interface MergeLines { +/** Result of mergeChildLists — decides pass/fail and builds resolved lines. */ +interface MergeResult { resolved: string[] pass: boolean } +/** Result of mergeNode — rendering only; pass/fail is decided by the caller + * via matchesNode, not by mergeNode itself. */ +type ResolvedLines = string[] + function isRegexName(name?: AriaRegex | string): name is AriaRegex { return typeof name === 'object' && name !== null && 'pattern' in name } @@ -242,7 +262,7 @@ function mergeChildLists( templates: AriaTemplateNode[], indent: string, containerMode?: ContainerMode -): MergeLines { +): MergeResult { // fragment = its children (a fragment has no semantics of its own) children = children.flatMap((c) => typeof c !== 'string' && c.role === 'fragment' ? c.children : [c] @@ -272,8 +292,7 @@ function mergeChildLists( const ti = recoveredPairs.get(ci) if (ti !== undefined) { // recursively merge for matched pairs to preserve template pattern on matched branches. - const r = mergeNode(children[ci], templates[ti], indent) - resolved.push(...r.resolved) + resolved.push(...mergeNode(children[ci], templates[ti], indent)) } else { // on unpaired child branch, we fully update with actual dom render. resolved.push(...renderChildLines(children[ci], indent)) @@ -288,8 +307,7 @@ function mergeChildLists( for (let ci = 0; ci < children.length; ci++) { const ti = pairs.get(ci) if (ti !== undefined) { - const r = mergeNode(children[ci], templates[ti], indent) - resolved.push(...r.resolved) + resolved.push(...mergeNode(children[ci], templates[ti], indent)) } } @@ -302,7 +320,7 @@ function mergeChildListsEqual( children: (AriaNode | string)[], templates: AriaTemplateNode[], indent: string -): MergeLines { +): MergeResult { const resolved: string[] = [] const allPositionalMatched = @@ -311,8 +329,7 @@ function mergeChildListsEqual( for (let ci = 0; ci < children.length; ci++) { if (ci < templates.length) { - const r = mergeNode(children[ci], templates[ci], indent) - resolved.push(...r.resolved) + resolved.push(...mergeNode(children[ci], templates[ci], indent)) } else { resolved.push(...renderChildLines(children[ci], indent)) } @@ -321,29 +338,33 @@ function mergeChildListsEqual( return { resolved, pass: allPositionalMatched } } +/** Render a single actual node through the template's lens. + * + * Intentionally returns only resolved lines, not pass/fail. This is + * the unusual part of the design: pass/fail is decided *before* this + * function is called, by mergeChildLists (which uses matchesNode to + * pair children and determine pass). mergeNode is called only after + * pairing is settled — it doesn't need to re-decide, and couldn't + * change the outcome if it tried, because list-level decisions (which + * children to pair, which to skip) have already been made. */ function mergeNode( node: AriaNode | string, template: AriaTemplateNode, indent: string -): MergeLines { +): ResolvedLines { // Both text node if (typeof node === 'string' && template.kind === 'text') { const matched = matchesTextValue(node, template.text) const resolvedText = matched && cachedRegex(template.text) ? formatTextValue(template.text) : node - const line = `${indent}- text: ${resolvedText}` - return { resolved: [line], pass: matched } + return [`${indent}- text: ${resolvedText}`] } // One text node and the other not if (typeof node === 'string' || template.kind === 'text') { - const resolved = renderChildLines(node, indent) - return { resolved, pass: false } + return renderChildLines(node, indent) } - // Match role name, e.g. `- role` - let namePass = matchesStringOrRegex(node.name, template.name) - // Resolved key (e.g. `- heading "Hello" [level=1]`): // adopt the template's lens for both name and attributes. // template omits name (e.g. `- heading`) → resolved omits it @@ -355,14 +376,20 @@ function mergeNode( // Recurse into children — if template omits children, the lens says // "don't care", so we skip (don't render children in resolved output). - const childResult = template.children + const childLines = template.children ? mergeChildLists( node.children, template.children, `${indent} `, template.containerMode - ) - : { resolved: [] as string[], pass: true } + ).resolved + : [] + + // Build directive line (/children) rendered before children, + // and prop pseudo-lines rendered after children. + const resolvedDirective: string[] = [] + if (template.containerMode && template.containerMode !== 'contain') + resolvedDirective.push(`${indent} - /children: ${template.containerMode}`) const resolvedPseudo: string[] = [] const allPropKeys = new Set([ @@ -383,45 +410,19 @@ function mergeNode( } } - let propsPass = true - if (template.props) { - for (const [key, tv] of Object.entries(template.props)) { - if (!matchesTextValue(node.props[key] || '', tv)) { - propsPass = false - break - } - } - } - - const attrPass = - (template.level === undefined || template.level === node.level) && - (template.checked === undefined || template.checked === node.checked) && - (template.disabled === undefined || template.disabled === node.disabled) && - (template.expanded === undefined || template.expanded === node.expanded) && - (template.pressed === undefined || template.pressed === node.pressed) && - (template.selected === undefined || template.selected === node.selected) - - const pass = namePass && attrPass && propsPass && childResult.pass - const resolved: string[] = [] - - const resolvedDirective: string[] = [] - if (template.containerMode && template.containerMode !== 'contain') { - resolvedDirective.push(`${indent} - /children: ${template.containerMode}`) - } - const hasExtra = resolvedDirective.length + resolvedPseudo.length > 0 - if (!childResult.resolved.length && !hasExtra) { + if (!childLines.length && !hasExtra) { // one liner node with no props, e.g. `- role "name" [props]` resolved.push(`${indent}- ${resolvedKey}`) } else if ( - childResult.resolved.length === 1 && - childResult.resolved[0].trimStart().startsWith('- text: ') && + childLines.length === 1 && + childLines[0].trimStart().startsWith('- text: ') && !hasExtra ) { // one liner node with text child, e.g. `- role "name" [props]: text` - const text = childResult.resolved[0].trimStart().slice('- text: '.length) + const text = childLines[0].trimStart().slice('- text: '.length) resolved.push(`${indent}- ${resolvedKey}: ${text}`) } else { // multi-line node with children and/or props, e.g. @@ -431,9 +432,9 @@ function mergeNode( // - /prop: value resolved.push(`${indent}- ${resolvedKey}:`) resolved.push(...resolvedDirective) - resolved.push(...childResult.resolved) + resolved.push(...childLines) resolved.push(...resolvedPseudo) } - return { resolved, pass } + return resolved } From 1e3ede9c5a743d4f6a55d3618aa4ea44e01c3dcd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:16:05 +0900 Subject: [PATCH 06/15] refactor: minor slop --- src/aria/match.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index c9cfefe..eb87c3f 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -153,10 +153,6 @@ interface MergeResult { pass: boolean } -/** Result of mergeNode — rendering only; pass/fail is decided by the caller - * via matchesNode, not by mergeNode itself. */ -type ResolvedLines = string[] - function isRegexName(name?: AriaRegex | string): name is AriaRegex { return typeof name === 'object' && name !== null && 'pattern' in name } @@ -351,7 +347,7 @@ function mergeNode( node: AriaNode | string, template: AriaTemplateNode, indent: string -): ResolvedLines { +): string[] { // Both text node if (typeof node === 'string' && template.kind === 'text') { const matched = matchesTextValue(node, template.text) From 693a2d8c36d67396930c38f8ca4befb6f1be4098 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:20:49 +0900 Subject: [PATCH 07/15] refactor: comment slop --- src/aria/match.ts | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index eb87c3f..d4fac2f 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -61,25 +61,10 @@ import { formatTextValue, formatNameValue } from './template' // convertToBestGuessRegex) and overwrites the snapshot wholesale. // Our merge preserves user-edited patterns from the expected side. // -// Two-pass pairing in mergeChildLists: -// Pass 1: O(C) greedy left-to-right, full-depth matchesNode. -// Determines pass (all templates matched = pass). This is the same -// algorithm as Playwright's containsList. -// Pass 2: O(C × T) unordered, full-depth matchesNode (only on failure). -// Recovers exact pairs that pass 1's greedy scan missed — e.g. -// template [paragraph "wrong", button /\d+/] vs children [paragraph, -// button]: pass 1 fails paragraph and advances past it, then can't -// match button against the paragraph template. Pass 2 scans all -// children per template and finds the button match, preserving the -// regex in the merge output instead of dumping a full literal snapshot. -// pass is already false — pass 2 only improves merge quality. -// -// Complexity: matchesNode recurses with O(C × T) internally (via -// containsList) at each tree level, so the total work across the tree -// is already O(N × T) where N = total actual nodes, T = total template -// nodes. Pass 2 adds a factor of T at the failing level only (O(C × T) -// calls instead of O(C)), each recursing O(subtree). This only triggers -// on failure and sibling lists are small in practice. +// Complexity: matchesNode is always full-depth (recurses the subtree), +// so each call is O(subtree × template). The contain-mode pass 2 +// (pairChildrenFull) adds a factor of T at the failing level only. +// This only triggers on failure and sibling lists are small in practice. // --------------------------------------------------------------------------- /** @@ -200,7 +185,7 @@ function renderResolvedKey(node: AriaNode, template: AriaTemplateRoleNode): stri return key } -// --- Pairing + merge --- +// --- Contain-mode pairing helpers --- function pairChildren( children: (AriaNode | string)[], @@ -273,8 +258,7 @@ function mergeChildLists( const resolved: string[] = [] - // we allow matching as subset so it can pass with - // children.length >= templates.length === pairs.size + // Contain mode (default): subsequence match via two-pass pairing. const pairs = pairChildren(children, templates) const allTemplatesMatched = templates.length === pairs.size @@ -298,8 +282,7 @@ function mergeChildLists( return { resolved, pass: false } } - // All templates matched (full-depth) — pass is true. - // mergeNode is only called here for rendering, not for pass/fail. + // All templates paired — render only matched children (contain = skip extras). for (let ci = 0; ci < children.length; ci++) { const ti = pairs.get(ci) if (ti !== undefined) { From a9c34983d175fd5461aecbd771e9310bf86adea8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:28:32 +0900 Subject: [PATCH 08/15] feat: /children: deep-equal --- src/aria/match.ts | 36 +++++++++++++++++------------ test/aria.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index d4fac2f..27716c6 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -252,8 +252,13 @@ function mergeChildLists( t.kind === 'role' && t.role === 'fragment' ? t.children || [] : [t] ) - if (containerMode === 'equal') { - return mergeChildListsEqual(children, templates, indent) + if (containerMode === 'equal' || containerMode === 'deep-equal') { + return mergeChildListsEqual( + children, + templates, + indent, + containerMode === 'deep-equal' + ) } const resolved: string[] = [] @@ -293,22 +298,25 @@ function mergeChildLists( return { resolved, pass: true } } -/** Equal mode: positional 1:1 matching. Pass iff same count and every - * positional pair matches (full-depth). */ +/** Equal/deep-equal mode: positional 1:1 matching. Pass iff same count + * and every positional pair matches (full-depth). When isDeepEqual is + * true, descendant nodes without explicit containerMode inherit equal + * semantics. */ function mergeChildListsEqual( children: (AriaNode | string)[], templates: AriaTemplateNode[], - indent: string + indent: string, + isDeepEqual: boolean ): MergeResult { const resolved: string[] = [] const allPositionalMatched = children.length === templates.length && - children.every((c, i) => matchesNode(c, templates[i], false)) + children.every((c, i) => matchesNode(c, templates[i], isDeepEqual)) for (let ci = 0; ci < children.length; ci++) { if (ci < templates.length) { - resolved.push(...mergeNode(children[ci], templates[ci], indent)) + resolved.push(...mergeNode(children[ci], templates[ci], indent, isDeepEqual)) } else { resolved.push(...renderChildLines(children[ci], indent)) } @@ -329,7 +337,8 @@ function mergeChildListsEqual( function mergeNode( node: AriaNode | string, template: AriaTemplateNode, - indent: string + indent: string, + isDeepEqual = false ): string[] { // Both text node if (typeof node === 'string' && template.kind === 'text') { @@ -355,13 +364,12 @@ function mergeNode( // Recurse into children — if template omits children, the lens says // "don't care", so we skip (don't render children in resolved output). + // When isDeepEqual is inherited and the template doesn't set its own + // containerMode, propagate equal semantics to descendants. + const effectiveMode = template.containerMode ?? (isDeepEqual ? 'equal' : undefined) const childLines = template.children - ? mergeChildLists( - node.children, - template.children, - `${indent} `, - template.containerMode - ).resolved + ? mergeChildLists(node.children, template.children, `${indent} `, effectiveMode) + .resolved : [] // Build directive line (/children) rendered before children, diff --git a/test/aria.test.ts b/test/aria.test.ts index 02eadc7..f6dca0a 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -4274,7 +4274,7 @@ describe('/children directive', () => { `) }) - test('/children: deep-equal — extra children rejected (not yet deep)', () => { + test('/children: deep-equal — extra children rejected', () => { const html = `
  • A
  • @@ -4282,7 +4282,6 @@ describe('/children directive', () => {
  • C
` - // deep-equal is not yet implemented; currently falls back to contain. expect( match( html, @@ -4319,6 +4318,58 @@ describe('/children directive', () => { `) }) + test('/children: deep-equal — propagates equal to descendants', () => { + // Inner list has 2 items but template only mentions 1. + // With contain this would pass; deep-equal rejects it. + const html = ` +
    +
  • +
      +
    • X
    • +
    • Y
    • +
    +
  • +
+ ` + expect( + match( + html, + ` + - list: + - /children: deep-equal + - listitem: + - list: + - listitem: X + ` + ) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: + - list: + - listitem: X + - listitem: "Y" + ", + "actualResolved": " + - list: + - listitem: + - list: + - listitem: X + - listitem: "Y" + ", + "expected": " + - list: + - /children: deep-equal + - listitem: + - list: + - listitem: X + ", + "pass": false, + } + `) + }) + test('/children: contain — behaves the same as default', () => { const html = `
    @@ -4335,8 +4386,7 @@ describe('/children directive', () => { - /children: contain - listitem: A - listitem: C - `, - { assertInvariant: false } + ` ) ).toMatchInlineSnapshot(` { From 3bd43dff5f05428a05af8d8e8a155ab9c4adbf52 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:37:20 +0900 Subject: [PATCH 09/15] fix: fix deep-equal with subset --- src/aria/match.ts | 24 +++++++++++++++--------- test/aria.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index 27716c6..1a3eea6 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -362,15 +362,21 @@ function mergeNode( // template has attr (e.g. [level=1]) → resolved includes it const resolvedKey = renderResolvedKey(node, template) - // Recurse into children — if template omits children, the lens says - // "don't care", so we skip (don't render children in resolved output). - // When isDeepEqual is inherited and the template doesn't set its own - // containerMode, propagate equal semantics to descendants. - const effectiveMode = template.containerMode ?? (isDeepEqual ? 'equal' : undefined) - const childLines = template.children - ? mergeChildLists(node.children, template.children, `${indent} `, effectiveMode) - .resolved - : [] + // Recurse into children. + // In contain mode (default), omitting children means "don't care" — skip. + // In equal/deep-equal mode, omitting children means "must have zero" — + // render actual children so the diff surfaces the mismatch. + const effectiveMode = template.containerMode ?? (isDeepEqual ? 'equal' : 'contain') + const expectsChildren = template.children || effectiveMode !== 'contain' + let childLines: string[] = [] + if (expectsChildren) { + childLines = mergeChildLists( + node.children, + template.children || [], + `${indent} `, + effectiveMode + ).resolved + } // Build directive line (/children) rendered before children, // and prop pseudo-lines rendered after children. diff --git a/test/aria.test.ts b/test/aria.test.ts index f6dca0a..33e729c 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -4370,6 +4370,46 @@ describe('/children directive', () => { `) }) + test('/children: deep-equal — omitted children means "must have zero"', () => { + // Template omits children on listitem, but actual listitem has a child. + // deep-equal propagates equal to descendants, so this should fail and + // render the actual children in the diff (not hide them). + const html = ` +
      +
    • A
    • +
    + ` + expect( + match( + html, + ` + - list: + - /children: deep-equal + - listitem + ` + ) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: + - strong: A + ", + "actualResolved": " + - list: + - listitem: + - strong: A + ", + "expected": " + - list: + - /children: deep-equal + - listitem + ", + "pass": false, + } + `) + }) + test('/children: contain — behaves the same as default', () => { const html = `
      From 492eab43921dcc019dc980762a58d100628ac468 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:39:06 +0900 Subject: [PATCH 10/15] refactor: minor slop --- src/aria/match.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index 1a3eea6..92b388f 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -367,15 +367,15 @@ function mergeNode( // In equal/deep-equal mode, omitting children means "must have zero" — // render actual children so the diff surfaces the mismatch. const effectiveMode = template.containerMode ?? (isDeepEqual ? 'equal' : 'contain') - const expectsChildren = template.children || effectiveMode !== 'contain' let childLines: string[] = [] - if (expectsChildren) { - childLines = mergeChildLists( + if (template.children || effectiveMode !== 'contain') { + const childrenResult = mergeChildLists( node.children, template.children || [], `${indent} `, effectiveMode - ).resolved + ) + childLines = childrenResult.resolved } // Build directive line (/children) rendered before children, From 9e8101f2f0aac327e7c4163a0dcc8760d8aef46e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:50:03 +0900 Subject: [PATCH 11/15] chore: lint --- src/aria/match.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index 92b388f..88c86c1 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -381,8 +381,9 @@ function mergeNode( // Build directive line (/children) rendered before children, // and prop pseudo-lines rendered after children. const resolvedDirective: string[] = [] - if (template.containerMode && template.containerMode !== 'contain') + if (template.containerMode && template.containerMode !== 'contain') { resolvedDirective.push(`${indent} - /children: ${template.containerMode}`) + } const resolvedPseudo: string[] = [] const allPropKeys = new Set([ From 996454d9c389f88fd74a4af8e64009d1ee71d30e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 10:54:41 +0900 Subject: [PATCH 12/15] test: add /children directive match tests Tests for equal, deep-equal, and contain modes including: - directive preserved on matched branch, purged on failed - deep-equal propagation to descendants - deep-equal with omitted children means "must have zero" Co-Authored-By: Claude Opus 4.6 (1M context) --- test/aria.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/aria.test.ts b/test/aria.test.ts index 33e729c..e07eba7 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -4410,6 +4410,65 @@ describe('/children directive', () => { `) }) + test('/children: equal — resolved preserves directive on matched branch, purges on failed', () => { + // Two sibling lists both with /children: equal. + // First list matches exactly → directive preserved in resolved. + // Second list has extra child → matchesNode fails, branch re-rendered + // from actual DOM (directive lost). + const html = ` +
      • A
      • B
      +
      • X
      • Y
      • Z
      + ` + expect( + match( + html, + ` + - list: + - /children: equal + - listitem: A + - listitem: B + - list: + - /children: equal + - listitem: X + - listitem: Z + ` + ) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: A + - listitem: B + - list: + - listitem: X + - listitem: "Y" + - listitem: Z + ", + "actualResolved": " + - list: + - /children: equal + - listitem: A + - listitem: B + - list: + - listitem: X + - listitem: "Y" + - listitem: Z + ", + "expected": " + - list: + - /children: equal + - listitem: A + - listitem: B + - list: + - /children: equal + - listitem: X + - listitem: Z + ", + "pass": false, + } + `) + }) + test('/children: contain — behaves the same as default', () => { const html = `
        From 00d3075d18c175aecfad4df8d5dff4e603e28ed5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 11:02:08 +0900 Subject: [PATCH 13/15] refactor: minor slop --- src/aria/match.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index 88c86c1..c625278 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -363,20 +363,14 @@ function mergeNode( const resolvedKey = renderResolvedKey(node, template) // Recurse into children. - // In contain mode (default), omitting children means "don't care" — skip. - // In equal/deep-equal mode, omitting children means "must have zero" — - // render actual children so the diff surfaces the mismatch. - const effectiveMode = template.containerMode ?? (isDeepEqual ? 'equal' : 'contain') - let childLines: string[] = [] - if (template.children || effectiveMode !== 'contain') { - const childrenResult = mergeChildLists( - node.children, - template.children || [], - `${indent} `, - effectiveMode - ) - childLines = childrenResult.resolved - } + // `childResult.pass` can be ignored since parent `mergeChildLists` already decided pass/fail. + const childResult = mergeChildLists( + node.children, + template.children || [], + `${indent} `, + template.containerMode ?? (isDeepEqual ? 'equal' : 'contain') + ) + const childLines = childResult.resolved // Build directive line (/children) rendered before children, // and prop pseudo-lines rendered after children. From 8f0253e953be923107bb7917fd86e0f8e4f4bbbc Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 11:06:58 +0900 Subject: [PATCH 14/15] refactor: minor slop --- src/aria/match.ts | 21 +++++++++------------ src/aria/template.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index c625278..232f06d 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -374,12 +374,11 @@ function mergeNode( // Build directive line (/children) rendered before children, // and prop pseudo-lines rendered after children. - const resolvedDirective: string[] = [] + const resolvedPseudo: string[] = [] if (template.containerMode && template.containerMode !== 'contain') { - resolvedDirective.push(`${indent} - /children: ${template.containerMode}`) + resolvedPseudo.push(`${indent} - /children: ${template.containerMode}`) } - const resolvedPseudo: string[] = [] const allPropKeys = new Set([ ...Object.keys(node.props), ...Object.keys(template.props || {}), @@ -399,29 +398,27 @@ function mergeNode( } const resolved: string[] = [] - const hasExtra = resolvedDirective.length + resolvedPseudo.length > 0 - if (!childLines.length && !hasExtra) { - // one liner node with no props, e.g. `- role "name" [props]` + if (!childLines.length && !resolvedPseudo.length) { + // one liner node with no props, e.g. `- role "name" [aria]` resolved.push(`${indent}- ${resolvedKey}`) } else if ( childLines.length === 1 && childLines[0].trimStart().startsWith('- text: ') && - !hasExtra + !resolvedPseudo.length ) { - // one liner node with text child, e.g. `- role "name" [props]: text` + // one liner node with text child, e.g. `- role "name" [aria]: text` const text = childLines[0].trimStart().slice('- text: '.length) resolved.push(`${indent}- ${resolvedKey}: ${text}`) } else { // multi-line node with children and/or props, e.g. - // - role "name" [props]: + // - role "name" [aria]: // - /children: equal - // - child // - /prop: value + // - child resolved.push(`${indent}- ${resolvedKey}:`) - resolved.push(...resolvedDirective) - resolved.push(...childLines) resolved.push(...resolvedPseudo) + resolved.push(...childLines) } return resolved diff --git a/src/aria/template.ts b/src/aria/template.ts index df1d0ce..353b436 100644 --- a/src/aria/template.ts +++ b/src/aria/template.ts @@ -59,11 +59,13 @@ function renderTemplateLines( const children = node.children || [] const pseudoLines: string[] = [] - if (node.containerMode && node.containerMode !== 'contain') + if (node.containerMode && node.containerMode !== 'contain') { pseudoLines.push(`${indent} - /children: ${node.containerMode}`) + } if (node.props) { - for (const [name, tv] of Object.entries(node.props)) + for (const [name, tv] of Object.entries(node.props)) { pseudoLines.push(`${indent} - /${name}: ${formatTextValue(tv)}`) + } } if (children.length === 0 && pseudoLines.length === 0) { @@ -80,5 +82,7 @@ function renderTemplateLines( } lines.push(`${indent}- ${key}:`) lines.push(...pseudoLines) - for (const child of children) renderTemplateLines(child, `${indent} `, lines) + for (const child of children) { + renderTemplateLines(child, `${indent} `, lines) + } } From cce3599077ecdb531e029b7e2140198971002034 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Mar 2026 11:07:57 +0900 Subject: [PATCH 15/15] refactor: rename --- src/aria/match.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/aria/match.ts b/src/aria/match.ts index 232f06d..09c2bc6 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -370,7 +370,6 @@ function mergeNode( `${indent} `, template.containerMode ?? (isDeepEqual ? 'equal' : 'contain') ) - const childLines = childResult.resolved // Build directive line (/children) rendered before children, // and prop pseudo-lines rendered after children. @@ -399,16 +398,16 @@ function mergeNode( const resolved: string[] = [] - if (!childLines.length && !resolvedPseudo.length) { + if (!childResult.resolved.length && !resolvedPseudo.length) { // one liner node with no props, e.g. `- role "name" [aria]` resolved.push(`${indent}- ${resolvedKey}`) } else if ( - childLines.length === 1 && - childLines[0].trimStart().startsWith('- text: ') && + childResult.resolved.length === 1 && + childResult.resolved[0].trimStart().startsWith('- text: ') && !resolvedPseudo.length ) { // one liner node with text child, e.g. `- role "name" [aria]: text` - const text = childLines[0].trimStart().slice('- text: '.length) + const text = childResult.resolved[0].trimStart().slice('- text: '.length) resolved.push(`${indent}- ${resolvedKey}: ${text}`) } else { // multi-line node with children and/or props, e.g. @@ -418,7 +417,7 @@ function mergeNode( // - child resolved.push(`${indent}- ${resolvedKey}:`) resolved.push(...resolvedPseudo) - resolved.push(...childLines) + resolved.push(...childResult.resolved) } return resolved