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..09c2bc6 100644 --- a/src/aria/match.ts +++ b/src/aria/match.ts @@ -8,15 +8,27 @@ import { type AriaRegex, type AriaTemplateNode, type AriaTemplateRoleNode, + type ContainerMode, } from './folk/isomorphic/ariaSnapshot' 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 @@ -49,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. // --------------------------------------------------------------------------- /** @@ -118,7 +115,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 { @@ -131,7 +132,8 @@ export function matchAriaTree( // Merge internals // --------------------------------------------------------------------------- -interface MergeLines { +/** Result of mergeChildLists — decides pass/fail and builds resolved lines. */ +interface MergeResult { resolved: string[] pass: boolean } @@ -183,7 +185,7 @@ function renderResolvedKey(node: AriaNode, template: AriaTemplateRoleNode): stri return key } -// --- Pairing + merge --- +// --- Contain-mode pairing helpers --- function pairChildren( children: (AriaNode | string)[], @@ -239,8 +241,9 @@ function renderChildLines(child: AriaNode | string, indent: string): string[] { function mergeChildLists( children: (AriaNode | string)[], templates: AriaTemplateNode[], - indent: string -): MergeLines { + indent: string, + containerMode?: ContainerMode +): 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] @@ -249,10 +252,18 @@ function mergeChildLists( t.kind === 'role' && t.role === 'fragment' ? t.children || [] : [t] ) + if (containerMode === 'equal' || containerMode === 'deep-equal') { + return mergeChildListsEqual( + children, + templates, + indent, + containerMode === 'deep-equal' + ) + } + 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 @@ -266,8 +277,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)) @@ -277,42 +287,72 @@ 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) { - const r = mergeNode(children[ci], templates[ti], indent) - resolved.push(...r.resolved) + resolved.push(...mergeNode(children[ci], templates[ti], indent)) } } return { resolved, pass: true } } +/** 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, + isDeepEqual: boolean +): MergeResult { + const resolved: string[] = [] + + const allPositionalMatched = + children.length === templates.length && + 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, isDeepEqual)) + } else { + resolved.push(...renderChildLines(children[ci], indent)) + } + } + + 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 { + indent: string, + isDeepEqual = false +): string[] { // 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 @@ -322,14 +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). - const childResult = template.children - ? mergeChildLists(node.children, template.children, `${indent} `) - : { resolved: [] as string[], pass: true } + // Recurse into children. + // `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') + ) - // Build pseudo-child lines for props + // Build directive line (/children) rendered before children, + // and prop pseudo-lines rendered after children. const resolvedPseudo: string[] = [] + if (template.containerMode && template.containerMode !== 'contain') { + resolvedPseudo.push(`${indent} - /children: ${template.containerMode}`) + } const allPropKeys = new Set([ ...Object.keys(node.props), @@ -349,48 +396,29 @@ 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[] = [] if (!childResult.resolved.length && !resolvedPseudo.length) { - // one liner node with no props, e.g. `- role "name" [props]` + // one liner node with no props, e.g. `- role "name" [aria]` resolved.push(`${indent}- ${resolvedKey}`) } else if ( childResult.resolved.length === 1 && childResult.resolved[0].trimStart().startsWith('- text: ') && !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 = childResult.resolved[0].trimStart().slice('- text: '.length) resolved.push(`${indent}- ${resolvedKey}: ${text}`) } else { // multi-line node with children and/or props, e.g. - // - role "name" [props]: - // - child + // - role "name" [aria]: + // - /children: equal // - /prop: value + // - child resolved.push(`${indent}- ${resolvedKey}:`) - resolved.push(...childResult.resolved) resolved.push(...resolvedPseudo) + resolved.push(...childResult.resolved) } - return { resolved, pass } + return resolved } diff --git a/src/aria/template.ts b/src/aria/template.ts index 9f23437..353b436 100644 --- a/src/aria/template.ts +++ b/src/aria/template.ts @@ -59,9 +59,13 @@ 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)) + for (const [name, tv] of Object.entries(node.props)) { pseudoLines.push(`${indent} - /${name}: ${formatTextValue(tv)}`) + } } if (children.length === 0 && pseudoLines.length === 0) { @@ -77,6 +81,8 @@ 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 cb68f89..f1aeb8b 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, } @@ -4184,3 +4184,342 @@ describe('aria-expanded', () => { `) }) }) + +// --------------------------------------------------------------------------- +// /children directive +// --------------------------------------------------------------------------- + +describe('/children directive', () => { + test('/children: equal — exact match passes', () => { + const html = ` + + ` + 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 rejected', () => { + const html = ` + + ` + expect( + match( + html, + ` + - list: + - /children: equal + - listitem: A + - listitem: C + ` + ) + ).toMatchInlineSnapshot(` + { + "actual": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "actualResolved": " + - list: + - listitem: A + - listitem: B + - listitem: C + ", + "expected": " + - list: + - /children: equal + - listitem: A + - listitem: C + ", + "pass": false, + } + `) + }) + + test('/children: deep-equal — extra children rejected', () => { + 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: + - /children: deep-equal + - listitem: A + - listitem: C + ", + "pass": false, + } + `) + }) + + 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 = ` + + ` + 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: 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 = ` + + ` + 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: 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 = ` + + + ` + 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 = ` + + ` + expect( + match( + html, + ` + - list: + - /children: contain + - listitem: A + - listitem: C + ` + ) + ).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 preserves /children directive', () => { + const t = parseAriaTemplate(` + - list: + - /children: equal + - listitem: A + `) + expect(renderAriaTemplate(t)).toMatchInlineSnapshot(` + "- list: + - /children: equal + - listitem: A" + `) + }) +}) 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', },