diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 746e9ce..d5c664f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,4 +39,4 @@ jobs: - run: vp i - run: vp run build - run: vp exec playwright install --with-deps - - run: vp run test -c vitest.ci.config.ts + - run: vp run test diff --git a/AGENTS.md b/AGENTS.md index 8ba7aa8..077cd2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,5 +32,5 @@ When syncing upstream or considering filing issues, review this list. If a diver - Iterate on aria snapshot utility feature ```sh -vp run test test/aria.test.ts --browser.headless --update +vp run test-chrome test/aria.test.ts --browser.headless --update ``` diff --git a/package.json b/package.json index be9ed92..3bf036f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "dev": "vp pack --watch --sourcemap", "build": "vp pack", "typecheck": "tsc -b", + "test-chrome": "vitest --project='*chromium*'", + "test-unit": "vitest --project=unit", "test": "vitest" }, "devDependencies": { @@ -41,8 +43,7 @@ "typescript": "^5.8.2", "vite": "^8.0.0", "vite-plus": "latest", - "vitest": "^4.1.0", - "yaml": "^2.8.2" + "vitest": "^4.1.0" }, "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a35d79e..fe26a13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: vitest: specifier: ^4.1.0 version: 4.1.0(@vitest/browser-playwright@4.1.0)(vite@8.0.0(yaml@2.8.2)) - yaml: - specifier: ^2.8.2 - version: 2.8.2 packages: @@ -1690,4 +1687,5 @@ snapshots: ws@8.19.0: {} - yaml@2.8.2: {} + yaml@2.8.2: + optional: true diff --git a/src/aria/folk/isomorphic/ariaSnapshot.ts b/src/aria/folk/isomorphic/ariaSnapshot.ts index 02649de..5c6bda3 100644 --- a/src/aria/folk/isomorphic/ariaSnapshot.ts +++ b/src/aria/folk/isomorphic/ariaSnapshot.ts @@ -186,16 +186,9 @@ export type ContainerMode = 'contain' | 'equal' | 'deep-equal' export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode -import type * as yamlTypes from 'yaml' - -type YamlLibrary = { - parseDocument: typeof yamlTypes.parseDocument - Scalar: typeof yamlTypes.Scalar - YAMLMap: typeof yamlTypes.YAMLMap - YAMLSeq: typeof yamlTypes.YAMLSeq - LineCounter: typeof yamlTypes.LineCounter -} +import type * as yamlTypes from '../../yaml' +type YamlLibrary = typeof yamlTypes type ParsedYamlPosition = { line: number; col: number } type ParsingOptions = yamlTypes.ParseOptions @@ -246,7 +239,11 @@ export function parseAriaSnapshot( const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string' if (itemIsString) { - const childNode = KeyParser.parse(item, parseOptions, errors) + const childNode = KeyParser.parse( + item as yamlTypes.Scalar, + parseOptions, + errors + ) if (childNode) { container.children = container.children || [] container.children.push(childNode) @@ -295,7 +292,7 @@ export function parseAriaSnapshot( } container.children.push({ kind: 'text', - text: textValue(value.value), + text: textValue(value.value as string), }) continue } @@ -332,7 +329,7 @@ export function parseAriaSnapshot( continue } container.props = container.props ?? {} - container.props[key.value.slice(1)] = textValue(value.value) + container.props[key.value.slice(1)] = textValue(value.value as string) continue } diff --git a/src/aria/index.ts b/src/aria/index.ts index 39db789..af592d1 100644 --- a/src/aria/index.ts +++ b/src/aria/index.ts @@ -10,6 +10,7 @@ import { type AriaTemplateNode, parseAriaSnapshotUnsafe, } from './folk/isomorphic/ariaSnapshot' +import * as yaml from './yaml' export type { AriaNode, AriaTemplateNode } from './folk/isomorphic/ariaSnapshot' @@ -19,6 +20,6 @@ export { renderAriaTemplate } from './template' export { matchAriaTree } from './match' -export function parseAriaTemplate(yamlLib: any, text: string): AriaTemplateNode { - return parseAriaSnapshotUnsafe(yamlLib, text) +export function parseAriaTemplate(text: string): AriaTemplateNode { + return parseAriaSnapshotUnsafe(yaml, text) } diff --git a/src/aria/yaml.md b/src/aria/yaml.md new file mode 100644 index 0000000..a0fcb65 --- /dev/null +++ b/src/aria/yaml.md @@ -0,0 +1,455 @@ +# Minimal YAML parser — design notes + +API-compatible subset of the [`yaml`](https://github.com/eemeli/yaml) package +by Eemeli Aro. Clean-room implementation — not a fork — covering only the +narrow YAML subset that aria snapshot templates use, to avoid the full +dependency (~97 KB min). + +## Supported subset + +- Sequences (`- item`) +- Maps (`key: value`) +- Scalars (plain strings, double-quoted strings, numbers, booleans, null) +- Indentation-based nesting + +**Not supported:** anchors, aliases, tags, merge keys, block scalars (`|`, `>`), +single-quoted scalars, flow collections (`[]`, `{}`), multi-document, comments. + +## Architecture comparison + +### Original: 3-stage pipeline + +``` +Source string + │ + ▼ + Lexer (lexer.ts) character cursor → flat string[] of lexemes + │ + ▼ + CST Parser (parser.ts) event-driven state machine (stack + dispatch) + │ feeds lexemes one at a time via next() + ▼ + Composer (compose/*.ts) shape-preserving tree walk → AST nodes + │ + ▼ + Document (Scalar / YAMLMap / YAMLSeq) +``` + +### This implementation: single-pass line-based parser + +``` +Source string + │ + ▼ + Split into lines (constructor) + │ + ▼ + Recursive descent (parseNode / parseSequence / parseMap / ...) + │ decides structure per-line, builds AST nodes directly + ▼ + Document (Scalar / YAMLMap / YAMLSeq) +``` + +## Why the original needs three stages + +### The lexer's job: boundary detection + +In full YAML, you can't know where a value ends without tracking indentation +context. For example: + +```yaml +key: value with: colon + still part of value +next: key +``` + +Is `with: colon` a nested map or part of a plain scalar? The answer depends on +indentation — the `:` after `with` isn't at a valid indicator position, so the +entire `value with: colon\nstill part of value` is a single plain scalar. + +The lexer resolves this with two variables: + +- **`indentValue`** — leading spaces on the current line +- **`indentNext`** — minimum indent for continuation lines (set by block + indicators like `-`, `:`, `?`) + +When the lexer encounters `-` or `:` followed by whitespace, it bumps +`indentNext = indentValue + 1`. Then `continueScalar()` checks whether the +next line's indent >= `indentNext` — if yes, the line is part of the current +scalar; if no, the scalar ends. + +This decouples "where tokens end" from "what tokens mean," which also enables +streaming/incremental parsing. + +### The CST parser's job: structure from events + +The parser receives a flat stream of lexemes (one at a time via `next()`) and +builds a nested CST tree using a **stack-based state machine**: + +```ts +parse(source: string): Token[] { + for (const lexeme of lex(source)) this.next(lexeme) + return this.end() +} +``` + +Each `next()` call classifies the lexeme (`seq-item-ind`, `map-value-ind`, +`scalar`, `space`, `newline`, ...) then calls `step()`, which dispatches based +on the top of `this.stack`: + +- Stack top is `block-seq` → `blockSequence()` handles `-` items +- Stack top is `block-map` → `blockMap()` handles `key: value` pairs +- Stack top is empty → `stream()` starts a new document + +Key mechanic — **retroactive reclassification**: when the parser receives a +plain scalar like `key`, it doesn't know yet if it's a map key or a standalone +value. It provisionally pushes a `FlowScalar`. When the _next_ lexeme is `:`, +`scalar()` retroactively promotes it to a `BlockMap` key. This is necessary +because the parser processes one token at a time without lookahead. + +Indent comparison decides push/pop: + +- `this.indent > node.indent` → child content, push onto stack +- `this.indent === node.indent` → sibling item +- `this.indent < node.indent` → pop (end of node) + +### The composer's job: type conversion + +The composer does a shape-preserving recursive walk over the finished CST: + +- `BlockMap` → `YAMLMap` with `Pair` items +- `BlockSequence` → `YAMLSeq` +- `FlowScalar` → `Scalar` (with escape processing, type resolution) + +No nodes are added, removed, reordered, or re-parented. The tree structure is +identical between CST and AST. The composer extracts props (anchors, tags, +comments) from `start`/`sep` token bags into named fields, and resolves raw +scalar strings into typed values (via schema tags). + +## Why our implementation doesn't need the three stages + +The supported subset avoids every case that makes the lexer necessary: + +- **No multi-line scalars** — each value fits on one line, so line-splitting + is sufficient for boundary detection +- **No block scalars** (`|`, `>`) — no need for `continueScalar()` or + `indentNext` tracking +- **No flow collections** (`[]`, `{}`) — no flow-level state machine +- **No retroactive reclassification** — we see the whole line at once, so + `isMapEntry()` can scan for `: ` before committing to a node type + +This means we can collapse all three stages into a single recursive-descent +parser that splits lines, decides structure per-line, and builds AST nodes +directly. + +### Why the line-based approach is correct for this subset + +The supported subset has a property that makes it structurally simple: + +> **Every value fits on a single line, and nesting is signaled solely by +> indentation on subsequent lines.** + +This makes the subset **recursive-descent friendly**, much like JSON. Compare +the grammar: + +``` +node = sequence | map | scalar +sequence = (INDENT '- ' node NEWLINE)+ at same indent +map = (INDENT key ': ' value NEWLINE)+ at same indent +scalar = plain-string | quoted-string | number | boolean | null +``` + +Each production can be decided by inspecting the current line: does it start +with `- `? Does it contain `: ` (outside quotes)? Otherwise it's a scalar. +Nesting is handled by recursive calls with an increased indent requirement. +This is the same pattern as a JSON parser checking for `[`, `{`, or a literal +to dispatch into `parseArray`, `parseObject`, or `parseValue`. + +Full YAML breaks this property — plain scalars can span multiple lines, quoted +strings can contain newlines, block scalars (`|`, `>`) are multi-line by +definition, and flow collections (`[]`, `{}`) introduce non-indentation-based +nesting. These require cross-line state that a simple recursive descent over +lines can't express. Our subset has none of them. + +#### Tokenize on the fly and decide + +The parser doesn't produce tokens as an intermediate data structure. Instead, +tokenization and structural decision are fused into single string-match +expressions: + +```ts +// tokenize (is there a - indicator?) + decide (parse as sequence) — one shot: +if (line.content.startsWith('- ')) return this.parseSequence(line.indent) + +// tokenize (find : indicator) + decide (parse as map) — one shot: +if (this.isMapEntry(line.content)) return this.parseMap(line.indent) + +// fallthrough — implicitly tokenized as scalar by not matching above: +return this.parseScalarValue(line.content, line.offset, line) +``` + +In the original, these are separate steps with intermediate data between them: + +``` +lexer emits '-' → data: string '-' +tokenType('-') → data: 'seq-item-ind' +blockSequence() matches → decision: push BlockSeq +``` + +There's no intermediate `'seq-item-ind'` value in our implementation. The +`startsWith('- ')` call simultaneously recognizes the indicator and branches +into the sequence path. This is valid because for single-line values, there's +no ambiguity that a token boundary would help resolve — the match _is_ the +decision. + +The full set of on-the-fly tokenize-and-decide operations: + +| Token | Recognition | Decision | +| ------------------ | ----------------------------------------------- | ------------------------------------------------------ | +| Sequence indicator | `content.startsWith('- ')` | → `parseSequence()` | +| Map colon | `findMapColon()` — scan for `: ` outside quotes | → `parseMap()` | +| Quoted scalar | `content.startsWith('"')` | → `parseQuotedScalar()` | +| Plain scalar | Fallthrough — none of the above matched | → `parseScalarValue()` | +| Indent level | `line.indent` (precomputed from leading spaces) | → compare against `baseIndent` for nesting/sibling/end | + +A formal lexer would produce the same information per line — `seq-item-ind`, +`map-value-ind`, `scalar`, etc. We just skip the intermediate representation +and match directly against the line string, because for single-line values +there's no ambiguity that a token boundary would help resolve. + +#### Comparison to the original's state requirements + +The original parser needs cross-line state (stack, flags, accumulated indent) +precisely for the features the subset excludes: + +| Cross-line state | What it handles | Why unnecessary here | +| -------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- | +| `indentNext` floor (lexer) | Multi-line scalar continuation | Every scalar is one line | +| Open-quote tracking (lexer) | Multi-line quoted strings | Every quoted string is one line | +| `atScalar` flag (parser) | Two-token scalar protocol (`\x1f` + source) | Scalar source visible inline on the line | +| `onKeyLine` flag (parser) | Whether `:` can start a nested map on same line as key | Full line visible — `isMapEntry()` decides upfront | +| `flowLevel` counter (lexer) | `[]`/`{}` change indicator rules | No flow collections | +| Stack for retroactive reclassification | Scalar promoted to map key when `:` arrives later | `findMapColon()` scans line before committing | + +### How parsing decisions are made + +The two implementations make the same structural decisions (sequence vs map vs +scalar, nesting depth, node boundaries), but derive them from different data at +different times. + +#### Original: accumulated state from token stream + +The parser has **zero lookahead** into the token stream. Each `next()` call +receives one lexeme and must decide what to do using only: + +1. **The token type** — classified from the lexeme string (`seq-item-ind`, + `map-value-ind`, `scalar`, `space`, `newline`, ...) +2. **The stack** — `this.stack` holds the nodes being built. `peek(1)` is the + stack top (the node currently being assembled), `peek(2)` is its parent. + The stack encodes "where we are" in the tree. +3. **Accumulated indent** — `this.indent` is built incrementally: `newline` + resets to 0, `space` tokens add their length, indicators (`-`, `:`, `?`) + add their length. This is compared against `node.indent` (stamped when the + node was pushed) to decide sibling vs child vs pop. +4. **Flags** — `atNewLine` (are we at the start of a line?), `onKeyLine` + (on the same line as a block map key?), `atScalar` (was the previous token + a `\x1f` scalar marker?). + +Because the parser can't see ahead, it sometimes commits provisionally and +fixes up later. The main example: when it receives a plain scalar like `name`, +it pushes a `FlowScalar` onto the stack. Only when the _next_ token arrives as +`:` does `scalar()` retroactively replace the `FlowScalar` with a `BlockMap` +entry where that scalar becomes the key. + +The dispatch pattern is: **stack top + token type → handler**. For example, +if the stack top is a `BlockSeq` and the token is `seq-item-ind` at the +sequence's indent, that's a new sibling item. If it's a scalar at a deeper +indent, that's a child value. + +#### This implementation: line content + recursion depth + +The parser sees the **entire current line** before committing to any decision. +The information available is: + +1. **Line content** — `line.content` is the trimmed text of the current line. + The parser inspects it directly: `startsWith('- ')` → sequence, + `findMapColon()` finds `: ` → map, otherwise → scalar. All decided in one + shot, no provisional commits needed. +2. **Line indent** — `line.indent` is the count of leading spaces, computed + upfront when lines are split. Compared against the `baseIndent` or + `minIndent` parameter to decide same-level vs nested vs end-of-node. +3. **Recursion depth** — the call stack itself encodes "where we are." + `parseSequence()` calls `parseInlineMap()` calls `parseScalarValue()` — + the nesting is implicit in the function calls rather than explicit in a + stack data structure. +4. **No flags** — no `atNewLine`, `onKeyLine`, `atScalar`. These are + unnecessary because the line-based approach never needs to track + cross-token state. + +The dispatch pattern is: **line content + indent vs parameter → recursive +call**. For example, `parseSequence(baseIndent=0)` loops while +`line.indent === baseIndent && line.content.startsWith('- ')`, then for each +item inspects the content after `- ` to decide: `isMapEntry()` → inline map, +or scalar. + +#### Summary + +| | Original | This implementation | +| -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------- | +| Data for decisions | Token type + stack + accumulated indent + flags | Line content + line indent + recursion depth | +| When decided | Incrementally, one token at a time; sometimes retroactively | All at once per line, before committing | +| Lookahead | None (purely reactive) | Full line visible (scan for `: `, `- `, etc.) | +| "Where are we" state | Explicit stack (`this.stack`) | Implicit call stack (recursion) | +| Indent tracking | Built token by token (newline→0, space→add, indicator→add) | Computed upfront per line (`stripped.length - trimmed.length`) | + +## Worked example: `- name: Alice` + +Tracing how the same input produces the same AST through the two approaches. + +**Final AST (identical for both):** + +``` +YAMLSeq [ + YAMLMap [ + { key: Scalar("name"), value: Scalar("Alice") } + ] +] +``` + +### Original: lexer → parser → composer + +**Stage 1 — Lexer** scans characters left to right, emits flat string tokens: + +``` +'\x02' doc-mode (start of document) +'-' seq-item-ind (the - character) +' ' space +'\x1f' scalar marker (next token is a scalar value) +'name' scalar source +':' map-value-ind (the : character) +' ' space +'\x1f' scalar marker +'Alice' scalar source +``` + +The lexer recognized `-` and `:` as indicators (each followed by space), +and wrapped the plain text in `\x1f` + source pairs. At this point there's +no tree — just a flat list of 9 strings. + +**Stage 2 — CST Parser** feeds lexemes into `next()` one at a time: + +``` +next('\x02') → stream(): push Document onto stack + stack: [Document] + +next('-') → document(): calls startBlockValue() + sees seq-item-ind, pushes BlockSeq{indent:0, items:[{start:['-']}]} + stack: [Document, BlockSeq] + +next(' ') → blockSequence(): appends space to current item.start + stack: [Document, BlockSeq] + +next('\x1f') → sets atScalar = true (next token will be a scalar value) + +next('name') → blockSequence(): calls startBlockValue() + sees scalar type, pushes FlowScalar{source:'name'} + stack: [Document, BlockSeq, FlowScalar('name')] + +next(':') → scalar(): sees map-value-ind while top is FlowScalar + retroactive promotion: replaces FlowScalar with + BlockMap{indent:2, items:[{key:FlowScalar('name'), sep:[':']}]} + stack: [Document, BlockSeq, BlockMap] + +next(' ') → blockMap(): appends space to current item.sep + stack: [Document, BlockSeq, BlockMap] + +next('\x1f') → sets atScalar = true + +next('Alice') → blockMap(): calls startBlockValue() + sees scalar type, pushes FlowScalar{source:'Alice'} + stack: [Document, BlockSeq, BlockMap, FlowScalar('Alice')] + +end() → pops FlowScalar('Alice') into BlockMap item.value + pops BlockMap into BlockSeq item.value + pops BlockSeq into Document.value + pops Document into tokens[] +``` + +Result: a CST tree with the structure already resolved. + +**Stage 3 — Composer** walks the CST tree (no cursor, no advancing): + +``` +composeDoc(Document) + └─ composeNode(BlockSeq) → YAMLSeq + └─ resolveBlockSeq() + └─ composeNode(BlockMap) → YAMLMap + └─ resolveBlockMap() + key: composeNode(FlowScalar('name')) → Scalar('name') + value: composeNode(FlowScalar('Alice')) → Scalar('Alice') + → Pair(Scalar('name'), Scalar('Alice')) +``` + +### This implementation: line-based recursive descent + +**Setup — Constructor** splits input into one line: + +``` +lines = [{ indent: 0, content: '- name: Alice', offset: 0 }] +pos = 0 +``` + +**Parsing — single pass through the call stack:** + +``` +parseRoot() + line.content starts with '- ' → parseSequence(baseIndent=0) + ┌─ content after '- ' is 'name: Alice' + │ isMapEntry('name: Alice')? + │ findMapColon() scans: n...a...m...e... ':' followed by ' ' → yes, at index 4 + │ + └─ parseInlineMap('name: Alice', offset=2, contentIndent=2) + splitMapEntry('name: Alice') + → key='name', colonOffset=6, valueStr='Alice', valueOffset=8 + keyScalar = Scalar('name', range=[2,6,6]) + parseScalarValue('Alice', offset=8) + → not quoted, not bool/null/number → Scalar('Alice', range=[8,13,13]) + map.items.push({ key: Scalar('name'), value: Scalar('Alice') }) + return YAMLMap + + seq.items.push(YAMLMap) + return YAMLSeq +``` + +### Side by side + +| Step | Original | This implementation | +| -------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Split input | Lexer scans chars → 9 lexemes | Constructor splits → 1 line | +| Detect `- ` | Parser receives `-` token, pushes `BlockSeq` | `parseNode()` checks `content.startsWith('- ')` | +| Detect `name` is a map key | Parser receives `name` as scalar, **then** `:` promotes it to `BlockMap` key (retroactive) | `isMapEntry()` scans the whole line for `: ` **before** committing | +| Build map | `BlockMap` assembled token by token on the stack | `parseInlineMap()` + `splitMapEntry()` in one call | +| Resolve scalar type | Composer's `composeScalar()` → schema tag matching | `parseScalarValue()` → inline if/else (bool, null, number, string) | +| Result | `YAMLSeq [ YAMLMap [ Pair(Scalar('name'), Scalar('Alice')) ] ]` | `YAMLSeq [ { key: Scalar('name'), value: Scalar('Alice') } ]` | + +## Code mapping + +For reviewing comparable logic between the two implementations: + +| This file | Original (`yaml` package) | Notes | +| --------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `parseDocument()` | `src/public-api.ts` → `parseDocument()` | | +| `Parser` constructor (line split) | `src/parse/lexer.ts` → `lex()` | | +| `Parser.parseRoot()` | `src/parse/parser.ts` → `parse()` | | +| `Parser.parseNode()` | `src/parse/parser.ts` → `startBlockValue()` | Dispatch by node type | +| `Parser.parseSequence()` | `src/parse/parser.ts` → `blockSequence()` | | +| `Parser.parseMap()` | `src/parse/parser.ts` → `blockMap()` | | +| `Parser.parseInlineMap()` | `src/parse/parser.ts` → `blockMap()` | Triggered within a seq item | +| `Parser.findMapColon()` | `src/parse/lexer.ts` → `plainScalar()` + `parser.ts` → `scalar()` | Lexer detects colon boundary; parser promotes scalar to block-map key on `: ` | +| `Parser.parseScalarValue()` | `src/parse/lexer.ts` → `plainScalar()` / `quotedScalar()` + `parser.ts` → `flowScalar()` | | +| `Parser.parseQuotedScalar()` | `src/parse/lexer.ts` → `quotedScalar()` | | +| `Parser.skipEmpty()` | `parser.ts` → `blockSequence()` / `blockMap()` | Newline/space token handling | +| `LineCounter` | `src/parse/line-counter.ts` | | +| `Scalar` / `YAMLMap` / `YAMLSeq` | `src/nodes/{Scalar,YAMLMap,YAMLSeq}.ts` | | +| `YAMLError` | `src/errors.ts` → `YAMLParseError` | | +| _(no equivalent)_ | `src/compose/*.ts` | Shape-preserving CST→AST tree walk; our parser builds AST directly | diff --git a/src/aria/yaml.test.ts b/src/aria/yaml.test.ts new file mode 100644 index 0000000..559c9f1 --- /dev/null +++ b/src/aria/yaml.test.ts @@ -0,0 +1,916 @@ +/** + * Unit tests for the minimal YAML parser. + * + * Tests the supported subset: sequences, maps, scalars (plain, quoted, + * numeric, boolean), indentation nesting, error reporting with positions. + * Test inputs are modeled after aria snapshot template patterns. + * + * The parser itself is API-compatible with the `yaml` package by Eemeli Aro + * (ISC License) — see src/aria/yaml.ts for attribution. + */ + +import { describe, expect, test } from 'vitest' +import { + LineCounter, + parseDocument, + Scalar, + YAMLError, + YAMLMap, + YAMLSeq, +} from './yaml' + +// TODO: snapshot tests + +// --------------------------------------------------------------------------- +// LineCounter (ported from vendor/yaml/tests/line-counter.ts) +// --------------------------------------------------------------------------- + +describe('LineCounter', () => { + test('single line — no newlines', () => { + const lc = new LineCounter() + parseDocument('- hello', { lineCounter: lc }) + expect(lc.lineStarts).toEqual([0]) + expect(lc.linePos(0)).toEqual({ line: 1, col: 1 }) + expect(lc.linePos(2)).toEqual({ line: 1, col: 3 }) + }) + + test('multiple lines', () => { + const lc = new LineCounter() + const src = `- a\n- b\n- c\n` + parseDocument(src, { lineCounter: lc }) + expect(lc.lineStarts).toEqual([0, 4, 8, 12]) + expect(lc.linePos(0)).toEqual({ line: 1, col: 1 }) + expect(lc.linePos(4)).toEqual({ line: 2, col: 1 }) + expect(lc.linePos(6)).toEqual({ line: 2, col: 3 }) + expect(lc.linePos(8)).toEqual({ line: 3, col: 1 }) + }) + + test('linePos for various offsets', () => { + const lc = new LineCounter() + const src = `- first\n- second\n` + parseDocument(src, { lineCounter: lc }) + // line 1: offsets 0–7, line 2 starts at 8 + expect(lc.linePos(0)).toEqual({ line: 1, col: 1 }) + expect(lc.linePos(7)).toEqual({ line: 1, col: 8 }) + expect(lc.linePos(8)).toEqual({ line: 2, col: 1 }) + expect(lc.linePos(10)).toEqual({ line: 2, col: 3 }) + }) +}) + +// --------------------------------------------------------------------------- +// parseDocument — sequences (YAML spec 2.1) +// --------------------------------------------------------------------------- + +describe('sequences', () => { + test('Example 2.1. Sequence of Scalars', () => { + const doc = parseDocument(` + - Mark McGwire + - Sammy Sosa + - Ken Griffey + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + expect(seq).toBeInstanceOf(YAMLSeq) + expect(seq.items).toHaveLength(3) + expect((seq.items[0] as Scalar).value).toBe('Mark McGwire') + expect((seq.items[1] as Scalar).value).toBe('Sammy Sosa') + expect((seq.items[2] as Scalar).value).toBe('Ken Griffey') + }) + + test('sequence with \\r\\n line endings', () => { + const doc = parseDocument(`- a\r\n- b\r\n- c\r\n`) + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + expect(items).toHaveLength(3) + expect((items[0] as Scalar).value).toBe('a') + expect((items[1] as Scalar).value).toBe('b') + expect((items[2] as Scalar).value).toBe('c') + }) + + test('sequence of quoted scalars', () => { + const doc = parseDocument(` + - "hello world" + - "foo \\"bar\\"" + `) + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + expect((items[0] as Scalar).value).toBe('hello world') + expect((items[1] as Scalar).value).toBe('foo "bar"') + }) + + test('sequence with numeric and boolean scalars', () => { + const doc = parseDocument(` + - 42 + - 3.14 + - true + - false + `) + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + expect((items[0] as Scalar).value).toBe(42) + expect((items[1] as Scalar).value).toBe(3.14) + expect((items[2] as Scalar).value).toBe(true) + expect((items[3] as Scalar).value).toBe(false) + }) + + test('empty input', () => { + const doc = parseDocument('') + expect(doc.errors).toHaveLength(0) + expect(doc.contents).toBeNull() + }) + + test('whitespace-only input', () => { + const doc = parseDocument(' \n \n') + expect(doc.errors).toHaveLength(0) + expect(doc.contents).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// parseDocument — maps +// --------------------------------------------------------------------------- + +describe('maps', () => { + test('simple mapping', () => { + const doc = parseDocument('- key: value') + expect(doc.errors).toHaveLength(0) + const map = (doc.contents as YAMLSeq).items[0] as YAMLMap + expect(map).toBeInstanceOf(YAMLMap) + expect(map.items).toHaveLength(1) + expect(map.items[0].key.value).toBe('key') + expect((map.items[0].value as Scalar).value).toBe('value') + }) + + test('map with quoted value', () => { + const doc = parseDocument('- text: "hello"') + expect(doc.errors).toHaveLength(0) + const map = (doc.contents as YAMLSeq).items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('hello') + }) + + test('map with numeric value', () => { + const doc = parseDocument('- count: 42') + expect(doc.errors).toHaveLength(0) + const map = (doc.contents as YAMLSeq).items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe(42) + }) + + test('map with boolean value', () => { + const doc = parseDocument('- enabled: true') + expect(doc.errors).toHaveLength(0) + const map = (doc.contents as YAMLSeq).items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe(true) + }) + + test('map with multiple entries (Example 2.2 shape)', () => { + const doc = parseDocument(` + - key1: val1 + key2: val2 + `) + expect(doc.errors).toHaveLength(0) + const map = (doc.contents as YAMLSeq).items[0] as YAMLMap + expect(map.items).toHaveLength(2) + expect(map.items[0].key.value).toBe('key1') + expect((map.items[0].value as Scalar).value).toBe('val1') + expect(map.items[1].key.value).toBe('key2') + expect((map.items[1].value as Scalar).value).toBe('val2') + }) + + test('top-level mapping', () => { + const doc = parseDocument('key: value') + expect(doc.errors).toHaveLength(0) + expect(doc.contents).toBeInstanceOf(YAMLMap) + const map = doc.contents as YAMLMap + expect(map.items[0].key.value).toBe('key') + expect((map.items[0].value as Scalar).value).toBe('value') + }) +}) + +// --------------------------------------------------------------------------- +// parseDocument — nested structures (YAML spec 2.3, 2.4) +// --------------------------------------------------------------------------- + +describe('nesting', () => { + test('Example 2.3. Mapping Scalars to Sequences', () => { + const doc = parseDocument(` + - american: + - Boston Red Sox + - Detroit Tigers + - national: + - New York Mets + - Chicago Cubs + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + expect(seq.items).toHaveLength(2) + + const m1 = seq.items[0] as YAMLMap + expect(m1.items[0].key.value).toBe('american') + const v1 = m1.items[0].value as YAMLSeq + expect(v1.items).toHaveLength(2) + expect((v1.items[0] as Scalar).value).toBe('Boston Red Sox') + expect((v1.items[1] as Scalar).value).toBe('Detroit Tigers') + + const m2 = seq.items[1] as YAMLMap + expect(m2.items[0].key.value).toBe('national') + const v2 = m2.items[0].value as YAMLSeq + expect(v2.items).toHaveLength(2) + expect((v2.items[0] as Scalar).value).toBe('New York Mets') + expect((v2.items[1] as Scalar).value).toBe('Chicago Cubs') + }) + + test('Example 2.4. Sequence of Mappings', () => { + const doc = parseDocument(` + - + name: Mark McGwire + hr: 65 + avg: 0.278 + - + name: Sammy Sosa + hr: 63 + avg: 0.288 + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + expect(seq.items).toHaveLength(2) + + const m1 = seq.items[0] as YAMLMap + expect(m1.items).toHaveLength(3) + expect(m1.items[0].key.value).toBe('name') + expect((m1.items[0].value as Scalar).value).toBe('Mark McGwire') + expect((m1.items[1].value as Scalar).value).toBe(65) + expect((m1.items[2].value as Scalar).value).toBe(0.278) + + const m2 = seq.items[1] as YAMLMap + expect(m2.items).toHaveLength(3) + expect((m2.items[0].value as Scalar).value).toBe('Sammy Sosa') + expect((m2.items[1].value as Scalar).value).toBe(63) + }) + + test('deeply nested sequences and maps', () => { + const doc = parseDocument(` + - list: + - listitem: + - link "Home" + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + const outerMap = seq.items[0] as YAMLMap + expect(outerMap.items[0].key.value).toBe('list') + const innerSeq = outerMap.items[0].value as YAMLSeq + const innerMap = innerSeq.items[0] as YAMLMap + expect(innerMap.items[0].key.value).toBe('listitem') + const deepSeq = innerMap.items[0].value as YAMLSeq + expect((deepSeq.items[0] as Scalar).value).toBe('link "Home"') + }) + + test('map entry with sequence value containing maps', () => { + const doc = parseDocument(` + - button "Submit": + - text: "Click me" + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + const map = seq.items[0] as YAMLMap + expect(map.items[0].key.value).toBe('button "Submit"') + const valSeq = map.items[0].value as YAMLSeq + const innerMap = valSeq.items[0] as YAMLMap + expect(innerMap.items[0].key.value).toBe('text') + expect((innerMap.items[0].value as Scalar).value).toBe('Click me') + }) + + test('multiple map entries in sequence value', () => { + const doc = parseDocument(` + - heading "Title": + - /children: equal + - text: "hello" + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + const map = seq.items[0] as YAMLMap + expect(map.items[0].key.value).toBe('heading "Title"') + const valSeq = map.items[0].value as YAMLSeq + expect(valSeq.items).toHaveLength(2) + const entry1 = valSeq.items[0] as YAMLMap + expect(entry1.items[0].key.value).toBe('/children') + expect((entry1.items[0].value as Scalar).value).toBe('equal') + const entry2 = valSeq.items[1] as YAMLMap + expect(entry2.items[0].key.value).toBe('text') + expect((entry2.items[0].value as Scalar).value).toBe('hello') + }) +}) + +// --------------------------------------------------------------------------- +// parseDocument — scalars +// --------------------------------------------------------------------------- + +describe('scalars', () => { + test('plain string', () => { + const doc = parseDocument('- hello world') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe('hello world') + }) + + test('double-quoted with \\n escape', () => { + const doc = parseDocument('- "hello\\nworld"') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe('hello\nworld') + }) + + test('double-quoted with \\t escape', () => { + const doc = parseDocument('- "col1\\tcol2"') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe('col1\tcol2') + }) + + test('double-quoted with escaped backslash', () => { + const doc = parseDocument('- "back\\\\slash"') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe('back\\slash') + }) + + test('double-quoted with escaped quotes', () => { + const doc = parseDocument('- "say \\"hi\\""') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe('say "hi"') + }) + + test('empty quoted string', () => { + const doc = parseDocument('- ""') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe('') + }) + + test('null values', () => { + const doc = parseDocument(` + - null + - ~ + `) + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + expect((items[0] as Scalar).value).toBeNull() + expect((items[1] as Scalar).value).toBeNull() + }) + + test('negative number', () => { + const doc = parseDocument('- -7') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe(-7) + }) + + test('float with exponent', () => { + const doc = parseDocument('- 1.5e3') + expect(doc.errors).toHaveLength(0) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe(1500) + }) +}) + +// --------------------------------------------------------------------------- +// parseDocument — range tracking +// --------------------------------------------------------------------------- + +describe('ranges', () => { + test('scalar ranges', () => { + const lc = new LineCounter() + const doc = parseDocument(`- hello\n- world`, { lineCounter: lc }) + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + // "hello" starts at offset 2 (after "- ") + expect((items[0] as Scalar).range[0]).toBe(2) + expect((items[0] as Scalar).range[1]).toBe(7) + // "world" starts at offset 10 (8 + 2) + expect((items[1] as Scalar).range[0]).toBe(10) + expect((items[1] as Scalar).range[1]).toBe(15) + }) + + test('map key ranges', () => { + const doc = parseDocument('- key: value') + expect(doc.errors).toHaveLength(0) + const map = (doc.contents as YAMLSeq).items[0] as YAMLMap + const keyRange = map.items[0].key.range + // "key" starts at offset 2 (after "- ") + expect(keyRange[0]).toBe(2) + expect(keyRange[1]).toBe(5) + }) + + test('sequence range starts at 0', () => { + const doc = parseDocument('- a\n- b') + expect(doc.errors).toHaveLength(0) + expect((doc.contents as YAMLSeq).range[0]).toBe(0) + }) + + test('quoted scalar range includes quotes', () => { + const doc = parseDocument('- "hi"') + expect(doc.errors).toHaveLength(0) + const s = (doc.contents as YAMLSeq).items[0] as Scalar + // range[0] = 2 (start of quote), range[1] = 6 (after closing quote) + expect(s.range[0]).toBe(2) + expect(s.range[1]).toBe(6) + }) +}) + +// --------------------------------------------------------------------------- +// parseDocument — error reporting +// --------------------------------------------------------------------------- + +describe('errors', () => { + test('YAMLError has message and pos', () => { + const err = new YAMLError('test error', [5, 6]) + expect(err.message).toBe('test error') + expect(err.pos).toEqual([5, 6]) + expect(err).toBeInstanceOf(Error) + }) + + test('unterminated quoted string reports error', () => { + const doc = parseDocument('- "unterminated') + expect(doc.errors.length).toBeGreaterThan(0) + expect(doc.errors[0].message).toMatch(/unterminated/i) + }) + + test('bad indentation reports error', () => { + const doc = parseDocument(`- a\n - b`) + expect(doc.errors.length).toBeGreaterThan(0) + }) +}) + +// --------------------------------------------------------------------------- +// instanceof checks (critical for ariaSnapshot.ts) +// --------------------------------------------------------------------------- + +describe('instanceof', () => { + test('Scalar', () => { + const doc = parseDocument('- hello') + const item = (doc.contents as YAMLSeq).items[0] + expect(item).toBeInstanceOf(Scalar) + expect(item).not.toBeInstanceOf(YAMLMap) + expect(item).not.toBeInstanceOf(YAMLSeq) + }) + + test('YAMLMap', () => { + const doc = parseDocument('- key: value') + const item = (doc.contents as YAMLSeq).items[0] + expect(item).toBeInstanceOf(YAMLMap) + expect(item).not.toBeInstanceOf(Scalar) + }) + + test('YAMLSeq', () => { + const doc = parseDocument('- hello') + expect(doc.contents).toBeInstanceOf(YAMLSeq) + expect(doc.contents).not.toBeInstanceOf(YAMLMap) + }) +}) + +// --------------------------------------------------------------------------- +// Aria template patterns (integration-style) +// --------------------------------------------------------------------------- + +describe('aria template patterns', () => { + test('plain role scalar', () => { + const doc = parseDocument('- heading "Title" [level=1]') + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + expect(items).toHaveLength(1) + expect(items[0]).toBeInstanceOf(Scalar) + expect((items[0] as Scalar).value).toBe('heading "Title" [level=1]') + }) + + test('complex nav tree', () => { + const doc = parseDocument(` + - navigation "Main": + - list: + - listitem: + - link "Home" + - listitem: + - link "About" + `) + expect(doc.errors).toHaveLength(0) + const seq = doc.contents as YAMLSeq + const nav = seq.items[0] as YAMLMap + expect(nav.items[0].key.value).toBe('navigation "Main"') + const navChildren = nav.items[0].value as YAMLSeq + const list = navChildren.items[0] as YAMLMap + expect(list.items[0].key.value).toBe('list') + const listChildren = list.items[0].value as YAMLSeq + expect(listChildren.items).toHaveLength(2) + }) + + test('mixed scalars and maps in sequence', () => { + const doc = parseDocument(` + - heading "Title" + - paragraph: + - text: "Hello world" + `) + expect(doc.errors).toHaveLength(0) + const items = (doc.contents as YAMLSeq).items + expect(items).toHaveLength(2) + expect(items[0]).toBeInstanceOf(Scalar) + expect(items[1]).toBeInstanceOf(YAMLMap) + }) + + test('/children containerMode', () => { + const doc = parseDocument(` + - list: + - /children: equal + - listitem "one" + - listitem "two" + `) + expect(doc.errors).toHaveLength(0) + const list = (doc.contents as YAMLSeq).items[0] as YAMLMap + const children = list.items[0].value as YAMLSeq + expect(children.items).toHaveLength(3) + const meta = children.items[0] as YAMLMap + expect(meta.items[0].key.value).toBe('/children') + expect((meta.items[0].value as Scalar).value).toBe('equal') + }) + + test('key with colon in quoted name', () => { + const src = '- link "http://example.com"' + const doc = parseDocument(src) + expect(doc.errors).toHaveLength(0) + // The colon is inside quotes, so this is a plain scalar, not a map + expect((doc.contents as YAMLSeq).items[0]).toBeInstanceOf(Scalar) + expect(((doc.contents as YAMLSeq).items[0] as Scalar).value).toBe( + 'link "http://example.com"' + ) + }) +}) + +// --------------------------------------------------------------------------- +// Coverage from test/aria.test.ts — every YAML shape fed to parseAriaTemplate +// --------------------------------------------------------------------------- + +describe('aria.test.ts effective coverage', () => { + // Helper: parse and return contents, asserting no errors + function parse(src: string) { + const doc = parseDocument(src) + expect(doc.errors).toHaveLength(0) + return doc.contents + } + + function seqItems(src: string) { + const seq = parse(src) as YAMLSeq + expect(seq).toBeInstanceOf(YAMLSeq) + return seq.items + } + + // -- indented templates (template-literal style) -------------------------- + + test('indented template with leading/trailing whitespace', () => { + const items = seqItems(` + - heading [level=1] + - button + `) + expect(items).toHaveLength(2) + expect((items[0] as Scalar).value).toBe('heading [level=1]') + expect((items[1] as Scalar).value).toBe('button') + }) + + // -- role with regex pattern name ----------------------------------------- + + test('role with regex name', () => { + const items = seqItems('- button /User \\d+/') + expect((items[0] as Scalar).value).toBe('button /User \\d+/') + }) + + test('regex name that does not match', () => { + const items = seqItems('- button /Goodbye/') + expect((items[0] as Scalar).value).toBe('button /Goodbye/') + }) + + // -- role with inline scalar text child ----------------------------------- + + test('role with plain text child', () => { + const items = seqItems('- listitem: One') + const map = items[0] as YAMLMap + expect(map.items[0].key.value).toBe('listitem') + expect((map.items[0].value as Scalar).value).toBe('One') + }) + + test('role with regex text child', () => { + const items = seqItems('- paragraph: /You have \\d+ notifications/') + const map = items[0] as YAMLMap + expect(map.items[0].key.value).toBe('paragraph') + expect((map.items[0].value as Scalar).value).toBe( + '/You have \\d+ notifications/' + ) + }) + + test('role with regex text child (no name)', () => { + const items = seqItems('- paragraph: /\\d+ errors/') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('/\\d+ errors/') + }) + + // -- quoted values escaping YAML special chars ---------------------------- + + test('quoted value with colon', () => { + const items = seqItems('- paragraph: "one: two"') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('one: two') + }) + + test('quoted value that looks like boolean', () => { + const items = seqItems('- paragraph: "true"') + const map = items[0] as YAMLMap + // Must be string, not boolean + expect((map.items[0].value as Scalar).value).toBe('true') + }) + + test('quoted value that looks like number', () => { + const items = seqItems('- paragraph: "123"') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('123') + }) + + // -- /url pseudo-attribute ------------------------------------------------ + + test('/url with regex value', () => { + const items = seqItems(` + - link: + - /url: /.*example.com/ + `) + const map = items[0] as YAMLMap + expect(map.items[0].key.value).toBe('link') + const children = map.items[0].value as YAMLSeq + const urlEntry = children.items[0] as YAMLMap + expect(urlEntry.items[0].key.value).toBe('/url') + expect((urlEntry.items[0].value as Scalar).value).toBe('/.*example.com/') + }) + + test('/url with plain value', () => { + const items = seqItems(` + - link: + - /url: https://example.com + `) + const urlEntry = ((items[0] as YAMLMap).items[0].value as YAMLSeq) + .items[0] as YAMLMap + expect(urlEntry.items[0].key.value).toBe('/url') + expect((urlEntry.items[0].value as Scalar).value).toBe('https://example.com') + }) + + // -- /placeholder pseudo-attribute ---------------------------------------- + + test('/placeholder pseudo-attribute', () => { + const items = seqItems(` + - textbox "Label": + - /placeholder: Enter name + `) + const map = items[0] as YAMLMap + expect(map.items[0].key.value).toBe('textbox "Label"') + const children = map.items[0].value as YAMLSeq + const ph = children.items[0] as YAMLMap + expect(ph.items[0].key.value).toBe('/placeholder') + expect((ph.items[0].value as Scalar).value).toBe('Enter name') + }) + + // -- link with text children + /url -------------------------------------- + + test('link with text children and /url', () => { + const items = seqItems(` + - link: + - text: Click here + - /url: /.*example.com/ + `) + const children = (items[0] as YAMLMap).items[0].value as YAMLSeq + expect(children.items).toHaveLength(2) + const textEntry = children.items[0] as YAMLMap + expect(textEntry.items[0].key.value).toBe('text') + expect((textEntry.items[0].value as Scalar).value).toBe('Click here') + const urlEntry = children.items[1] as YAMLMap + expect(urlEntry.items[0].key.value).toBe('/url') + }) + + // -- sibling maps at top level ------------------------------------------- + + test('sibling list maps', () => { + const items = seqItems(` + - list: + - listitem: A + - list: + - listitem: WRONG + `) + expect(items).toHaveLength(2) + expect(items[0]).toBeInstanceOf(YAMLMap) + expect(items[1]).toBeInstanceOf(YAMLMap) + expect((items[0] as YAMLMap).items[0].key.value).toBe('list') + expect((items[1] as YAMLMap).items[0].key.value).toBe('list') + }) + + // -- multiple top-level items: role-with-value + scalar ------------------- + + test('mixed map and scalar at top level', () => { + const items = seqItems(` + - button /\\d+/: Pattern + - paragraph: Original + `) + expect(items).toHaveLength(2) + const m1 = items[0] as YAMLMap + expect(m1.items[0].key.value).toBe('button /\\d+/') + expect((m1.items[0].value as Scalar).value).toBe('Pattern') + const m2 = items[1] as YAMLMap + expect(m2.items[0].key.value).toBe('paragraph') + expect((m2.items[0].value as Scalar).value).toBe('Original') + }) + + // -- deep navigation tree (4 levels) ------------------------------------- + + test('4-level deep navigation tree', () => { + const items = seqItems(` + - navigation "Main": + - list: + - listitem: + - button: Home + `) + const nav = items[0] as YAMLMap + expect(nav.items[0].key.value).toBe('navigation "Main"') + const list = (nav.items[0].value as YAMLSeq).items[0] as YAMLMap + expect(list.items[0].key.value).toBe('list') + const listitem = (list.items[0].value as YAMLSeq).items[0] as YAMLMap + expect(listitem.items[0].key.value).toBe('listitem') + const btn = (listitem.items[0].value as YAMLSeq).items[0] as YAMLMap + expect(btn.items[0].key.value).toBe('button') + expect((btn.items[0].value as Scalar).value).toBe('Home') + }) + + // -- navigation with /url at leaf ---------------------------------------- + + test('navigation tree with /url at leaf', () => { + const items = seqItems(` + - navigation "Main": + - list: + - listitem: + - link "Home": + - /url: /home + - listitem: + - link "About": + - /url: /about + `) + const nav = items[0] as YAMLMap + const list = (nav.items[0].value as YAMLSeq).items[0] as YAMLMap + const listItems = list.items[0].value as YAMLSeq + expect(listItems.items).toHaveLength(2) + // First listitem > link "Home" > /url + const li1 = listItems.items[0] as YAMLMap + const link1 = (li1.items[0].value as YAMLSeq).items[0] as YAMLMap + expect(link1.items[0].key.value).toBe('link "Home"') + const url1 = (link1.items[0].value as YAMLSeq).items[0] as YAMLMap + expect(url1.items[0].key.value).toBe('/url') + expect((url1.items[0].value as Scalar).value).toBe('/home') + }) + + // -- attributes: various forms ------------------------------------------- + + test('role with single attribute', () => { + const items = seqItems('- button [disabled]') + expect((items[0] as Scalar).value).toBe('button [disabled]') + }) + + test('role with attribute=value', () => { + const items = seqItems('- button [expanded=false]') + expect((items[0] as Scalar).value).toBe('button [expanded=false]') + }) + + test('role with name and attribute', () => { + const items = seqItems('- checkbox "A" [checked]') + expect((items[0] as Scalar).value).toBe('checkbox "A" [checked]') + }) + + test('role with name and attribute=mixed', () => { + const items = seqItems('- checkbox "A" [checked=mixed]') + expect((items[0] as Scalar).value).toBe('checkbox "A" [checked=mixed]') + }) + + test('role with name and attribute=false', () => { + const items = seqItems('- button "Menu" [expanded=false]') + expect((items[0] as Scalar).value).toBe('button "Menu" [expanded=false]') + }) + + // -- contain semantics: subsequence matching shapes ----------------------- + + test('contain semantics — subsequence', () => { + const items = seqItems(` + - list: + - listitem: A + - listitem: C + `) + const list = items[0] as YAMLMap + const children = list.items[0].value as YAMLSeq + expect(children.items).toHaveLength(2) + expect(((children.items[0] as YAMLMap).items[0].value as Scalar).value).toBe('A') + expect(((children.items[1] as YAMLMap).items[0].value as Scalar).value).toBe('C') + }) + + // -- single item shorthand ----------------------------------------------- + + test('single role without value', () => { + const items = seqItems('- list') + expect(items).toHaveLength(1) + expect((items[0] as Scalar).value).toBe('list') + }) + + test('single link without value', () => { + const items = seqItems('- link') + expect(items).toHaveLength(1) + expect((items[0] as Scalar).value).toBe('link') + }) + + // -- regex in map key (role name pattern) --------------------------------- + + test('regex in map key with scalar value', () => { + const items = seqItems(` + - button /item-\\d+/: Click + - button /user-\\d+/: Edit + `) + expect(items).toHaveLength(2) + expect((items[0] as YAMLMap).items[0].key.value).toBe('button /item-\\d+/') + expect(((items[0] as YAMLMap).items[0].value as Scalar).value).toBe('Click') + expect((items[1] as YAMLMap).items[0].key.value).toBe('button /user-\\d+/') + expect(((items[1] as YAMLMap).items[0].value as Scalar).value).toBe('Edit') + }) + + // -- map value with regex pattern ---------------------------------------- + + test('map with regex value', () => { + const items = seqItems(` + - button: Cancel + - paragraph: /\\w+/ + `) + expect(items).toHaveLength(2) + expect(((items[0] as YAMLMap).items[0].value as Scalar).value).toBe('Cancel') + expect(((items[1] as YAMLMap).items[0].value as Scalar).value).toBe('/\\w+/') + }) + + // -- heading with quoted name only (no children) -------------------------- + + test('heading with quoted name only', () => { + const items = seqItems('- heading "title"') + expect(items).toHaveLength(1) + // YAML sees this as a plain scalar (no colon), not a map + expect(items[0]).toBeInstanceOf(Scalar) + expect((items[0] as Scalar).value).toBe('heading "title"') + }) + + // -- textbox without name ------------------------------------------------- + + test('textbox without name, /placeholder child', () => { + const items = seqItems(` + - textbox: + - /placeholder: Enter name + `) + const map = items[0] as YAMLMap + expect(map.items[0].key.value).toBe('textbox') + const children = map.items[0].value as YAMLSeq + const ph = children.items[0] as YAMLMap + expect(ph.items[0].key.value).toBe('/placeholder') + expect((ph.items[0].value as Scalar).value).toBe('Enter name') + }) + + // -- YAML escaping of special characters (renderAriaTree roundtrip) ------- + + test('quoted value with escaped quotes', () => { + const items = seqItems('- paragraph: "\\"quoted\\""') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('"quoted"') + }) + + test('quoted value with hash (comment char)', () => { + const items = seqItems('- paragraph: "#comment"') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('#comment') + }) + + test('quoted value with @ symbol', () => { + const items = seqItems('- paragraph: "@at"') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('@at') + }) + + test('quoted value with brackets', () => { + const items = seqItems('- paragraph: "[bracket]"') + const map = items[0] as YAMLMap + expect((map.items[0].value as Scalar).value).toBe('[bracket]') + }) + + test('all special char values in sequence', () => { + const items = seqItems(` + - paragraph: "one: two" + - paragraph: "\\"quoted\\"" + - paragraph: "#comment" + - paragraph: "@at" + - paragraph: "[bracket]" + - paragraph: "true" + - paragraph: "123" + `) + expect(items).toHaveLength(7) + const values = items.map((i) => ((i as YAMLMap).items[0].value as Scalar).value) + expect(values).toEqual([ + 'one: two', + '"quoted"', + '#comment', + '@at', + '[bracket]', + 'true', + '123', + ]) + }) +}) diff --git a/src/aria/yaml.ts b/src/aria/yaml.ts new file mode 100644 index 0000000..f0f63af --- /dev/null +++ b/src/aria/yaml.ts @@ -0,0 +1,640 @@ +/** + * Minimal YAML parser for aria snapshot templates. + * + * API-compatible subset of the `yaml` package by Eemeli Aro. + * Clean-room implementation — not a fork — covering only the narrow + * YAML subset that aria templates actually use, to avoid the full + * dependency (~97 KB min). See ./yaml.md for architecture comparison + * with the original. + * + * Original yaml package: + * Copyright Eemeli Aro + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Range = [number, number, number] + +// --------------------------------------------------------------------------- +// AST node classes — API-compatible with the `yaml` package +// --------------------------------------------------------------------------- + +export class Scalar { + value: T + range: [number, number, number] + constructor(value: T, range: [number, number, number] = [0, 0, 0]) { + this.value = value + this.range = range + } +} + +export class YAMLMap { + items: { key: Scalar; value: Scalar | YAMLSeq | YAMLMap | null }[] + range: [number, number, number] + constructor(range: [number, number, number] = [0, 0, 0]) { + this.items = [] + this.range = range + } +} + +export class YAMLSeq { + items: (Scalar | YAMLMap | YAMLSeq)[] + range: [number, number, number] + constructor(range: [number, number, number] = [0, 0, 0]) { + this.items = [] + this.range = range + } +} + +// --------------------------------------------------------------------------- +// LineCounter — API-compatible with the `yaml` package +// --------------------------------------------------------------------------- + +export class LineCounter { + lineStarts: number[] = [0] + + addNewLine(offset: number) { + if (offset > this.lineStarts[this.lineStarts.length - 1]) + this.lineStarts.push(offset) + } + + linePos(offset: number): { line: number; col: number } { + let low = 0 + let high = this.lineStarts.length - 1 + while (low < high) { + const mid = (low + high + 1) >> 1 + if (this.lineStarts[mid] <= offset) low = mid + else high = mid - 1 + } + return { line: low + 1, col: offset - this.lineStarts[low] + 1 } + } +} + +// --------------------------------------------------------------------------- +// YAMLError +// --------------------------------------------------------------------------- + +export class YAMLError extends Error { + pos: [number, number] + constructor(message: string, pos: [number, number]) { + super(message) + this.pos = pos + } +} + +// --------------------------------------------------------------------------- +// parseDocument +// --------------------------------------------------------------------------- + +export interface ParseOptions { + lineCounter?: LineCounter + // unused + keepSourceTokens?: boolean + prettyErrors?: boolean + [key: string]: unknown +} + +interface ParsedDocument { + contents: Scalar | YAMLMap | YAMLSeq | null + errors: YAMLError[] +} + +export function parseDocument( + text: string, + options: ParseOptions = {} +): ParsedDocument { + const lineCounter = options.lineCounter + const errors: YAMLError[] = [] + + // Build line starts for the lineCounter + if (lineCounter) { + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') lineCounter.addNewLine(i + 1) + } + } + + try { + const parser = new Parser(text, errors) + const contents = parser.parseRoot() + return { contents, errors } + } catch (e) { + if (e instanceof YAMLError) { + errors.push(e) + return { contents: null, errors } + } + throw e + } +} + +// --------------------------------------------------------------------------- +// Internal line representation +// --------------------------------------------------------------------------- + +interface Line { + indent: number + content: string // trimmed content (no leading/trailing whitespace) + // below are only for error position reporting + offset: number // absolute offset of first non-whitespace char + lineOffset: number // absolute offset of start of line + raw: string // full line including leading whitespace +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/** + * Single-pass line-based recursive descent parser. + * + * Call graph (recursion points are parseNode calls): + * + * parseRoot + * → parseNode(minIndent) + * → parseSequence(baseIndent) + * → parseNode(baseIndent + 1) "- \n" then indented child + * → parseInlineMap(contentIndent) "- key: value" + * → parseScalarValue inline value + * → parseMapValue(contentIndent) "- key:\n" then child + * → parseNode(contentIndent + 1) indented child + * → parseNode(contentIndent) block seq at same indent + * → parseScalarValue "- scalar" + * → parseMap(baseIndent) + * → parseScalarValue inline value + * → parseMapValue "key:\n" then child + * → parseScalarValue + * → parseQuotedScalar starts with " + */ +class Parser { + private lines: Line[] + private pos: number // current line index + private text: string + private errors: YAMLError[] + + constructor(text: string, errors: YAMLError[]) { + this.text = text + this.errors = errors + this.lines = [] + this.pos = 0 + + let offset = 0 + const rawLines = text.split('\n') + for (const raw of rawLines) { + const stripped = raw.replace(/\r$/, '') + const trimmed = stripped.replace(/^\s+/, '') + const indent = stripped.length - trimmed.length + this.lines.push({ + indent, + content: trimmed, + offset: offset + indent, + lineOffset: offset, + raw: stripped, + }) + offset += raw.length + 1 // +1 for \n + } + + // Remove trailing empty lines + while ( + this.lines.length > 0 && + this.lines[this.lines.length - 1].content === '' + ) { + this.lines.pop() + } + } + + parseRoot(): Scalar | YAMLMap | YAMLSeq | null { + if (this.lines.length === 0) return null + const result = this.parseNode(0) + // Error on unconsumed non-empty lines at root level + this.skipEmpty() + if (this.pos < this.lines.length) { + const line = this.currentLine()! + this.addError('Unexpected scalar at node end', line.offset) + } + return result + } + + private currentLine(): Line | undefined { + return this.lines[this.pos] + } + + private parseNode(minIndent: number): Scalar | YAMLMap | YAMLSeq | null { + this.skipEmpty() + const line = this.currentLine() + if (!line || line.indent < minIndent) return null + + if (line.content.startsWith('- ') || line.content === '-') { + return this.parseSequence(line.indent) + } + if (this.isMapEntry(line.content)) { + return this.parseMap(line.indent) + } + // Single scalar + return this.parseScalarValue(line.content, line.offset, line) + } + + private parseSequence(baseIndent: number): YAMLSeq { + const startLine = this.currentLine()! + const seq = new YAMLSeq([startLine.offset - startLine.indent, 0, 0]) + let lastOffset = startLine.offset + + while (this.pos < this.lines.length) { + this.skipEmpty() + const line = this.currentLine() + if (!line || line.indent < baseIndent) break + if (line.indent > baseIndent) { + this.addError('Bad indentation of a sequence entry', line.offset) + break + } + + if (!line.content.startsWith('- ') && line.content !== '-') { + // Not a sequence item at this level — could be a map that follows + break + } + + // Content after "- " + const dashLen = line.content === '-' ? 1 : 2 + const itemContent = line.content.slice(dashLen) + const itemOffset = line.offset + dashLen + + this.pos++ + + if (itemContent === '' || itemContent.trim() === '') { + // "- \n" followed by indented content + const child = this.parseNode(baseIndent + 1) + if (child) { + seq.items.push(child) + lastOffset = this.peekLastOffset() + } + } else if (this.isMapEntry(itemContent)) { + // "- key: value" — inline map entry, possibly with more at same indent+2 + const map = this.parseInlineMap( + itemContent, + itemOffset, + baseIndent + dashLen, + line + ) + seq.items.push(map) + lastOffset = map.range[2] + } else { + // "- scalar" + const scalar = this.parseScalarValue(itemContent, itemOffset, line) + seq.items.push(scalar) + lastOffset = scalar.range[2] + } + } + + seq.range[1] = lastOffset + seq.range[2] = lastOffset + return seq + } + + private parseMap(baseIndent: number): YAMLMap { + const startLine = this.currentLine()! + const map = new YAMLMap([startLine.offset - startLine.indent, 0, 0]) + let lastOffset = startLine.offset + + while (this.pos < this.lines.length) { + this.skipEmpty() + const line = this.currentLine() + if (!line || line.indent < baseIndent) break + if (line.indent > baseIndent) { + this.addError('Bad indentation of a mapping entry', line.offset) + break + } + if (!this.isMapEntry(line.content)) break + + const { key, valueStr, colonOffset, valueOffset } = this.splitMapEntry( + line.content, + line.offset + ) + const keyScalar = new Scalar(key, [ + line.offset, + line.offset + key.length, + colonOffset, + ]) + + this.pos++ + + let value: Scalar | YAMLSeq | YAMLMap | null + if (valueStr === '') { + value = this.parseMapValue(baseIndent, colonOffset) + } else { + value = this.parseScalarValue(valueStr.trim(), valueOffset, line) + } + + map.items.push({ key: keyScalar, value }) + lastOffset = value ? this.getNodeEnd(value) : colonOffset + 1 + } + + map.range[1] = lastOffset + map.range[2] = lastOffset + return map + } + + /** + * Parse an inline map that starts after "- " in a sequence. + * E.g. "- key: value" or "- key:\n - child" + * May continue with more entries at the same indent level. + */ + private parseInlineMap( + firstEntry: string, + entryOffset: number, + contentIndent: number, + _sourceLine: Line + ): YAMLMap { + const map = new YAMLMap([entryOffset, 0, 0]) + let lastOffset = entryOffset + + // Parse first entry + const { key, valueStr, colonOffset, valueOffset } = this.splitMapEntry( + firstEntry, + entryOffset + ) + const keyScalar = new Scalar(key, [ + entryOffset, + entryOffset + key.length, + colonOffset, + ]) + + let value: Scalar | YAMLSeq | YAMLMap | null + if (valueStr === '') { + value = this.parseMapValue(contentIndent, colonOffset) + } else { + value = this.parseScalarValue(valueStr.trim(), valueOffset, _sourceLine) + } + + map.items.push({ key: keyScalar, value }) + lastOffset = value ? this.getNodeEnd(value) : colonOffset + 1 + + // Continue with more map entries at contentIndent + while (this.pos < this.lines.length) { + this.skipEmpty() + const line = this.currentLine() + if (!line || line.indent < contentIndent) break + if (line.indent > contentIndent) break + if (!this.isMapEntry(line.content)) break + // Ensure it's not a sequence item + if (line.content.startsWith('- ')) break + + const entry = this.splitMapEntry(line.content, line.offset) + const ks = new Scalar(entry.key, [ + line.offset, + line.offset + entry.key.length, + entry.colonOffset, + ]) + + this.pos++ + + let v: Scalar | YAMLSeq | YAMLMap | null + if (entry.valueStr === '') { + v = this.parseMapValue(contentIndent, entry.colonOffset) + } else { + v = this.parseScalarValue(entry.valueStr.trim(), entry.valueOffset, line) + } + + map.items.push({ key: ks, value: v }) + lastOffset = v ? this.getNodeEnd(v) : entry.colonOffset + 1 + } + + map.range[1] = lastOffset + map.range[2] = lastOffset + return map + } + + /** + * Parse the value of a map entry when the value is on subsequent lines. + * In YAML, a block sequence can start at the same indent as the key, + * but other block collections must be indented further. + */ + private parseMapValue( + contentIndent: number, + colonOffset: number + ): Scalar | YAMLSeq | YAMLMap | null { + this.skipEmpty() + const nextLine = this.currentLine() + if (!nextLine || nextLine.indent < contentIndent) { + return new Scalar(null, [colonOffset + 1, colonOffset + 1, colonOffset + 1]) + } + // Block sequence at same indent as content is allowed + if ( + nextLine.indent === contentIndent && + (nextLine.content.startsWith('- ') || nextLine.content === '-') + ) { + return this.parseNode(contentIndent)! + } + // Other content must be further indented + if (nextLine.indent > contentIndent) { + return this.parseNode(contentIndent + 1)! + } + return new Scalar(null, [colonOffset + 1, colonOffset + 1, colonOffset + 1]) + } + + private getNodeEnd(node: Scalar | YAMLSeq | YAMLMap): number { + return node.range[2] + } + + private peekLastOffset(): number { + if (this.pos > 0 && this.pos <= this.lines.length) { + const prev = this.lines[this.pos - 1] + return prev.lineOffset + prev.raw.length + } + return this.text.length + } + + // ------------------------------------------------------------------------- + // Scalar parsing + // ------------------------------------------------------------------------- + + private parseScalarValue(raw: string, offset: number, line: Line): Scalar { + const trimmed = raw.trim() + const trimStart = raw.indexOf(trimmed) + const adjOffset = offset + trimStart + const end = adjOffset + trimmed.length + const lineEnd = line.lineOffset + line.raw.length + + if (trimmed.startsWith('"')) { + return this.parseQuotedScalar(trimmed, adjOffset, lineEnd) + } + + // Boolean + if (trimmed === 'true' || trimmed === 'false') { + return new Scalar(trimmed === 'true', [adjOffset, end, lineEnd]) + } + + // Null + if (trimmed === 'null' || trimmed === '~') { + return new Scalar(null, [adjOffset, end, lineEnd]) + } + + // Number — integers and floats + if (trimmed !== '' && isNumeric(trimmed)) { + return new Scalar(Number(trimmed), [adjOffset, end, lineEnd]) + } + + // Plain string + return new Scalar(trimmed, [adjOffset, end, lineEnd]) + } + + private parseQuotedScalar( + raw: string, + offset: number, + lineEnd: number + ): Scalar { + let result = '' + let i = 1 // skip opening quote + while (i < raw.length) { + const ch = raw[i] + if (ch === '\\') { + i++ + if (i >= raw.length) { + this.addError('Unterminated double-quoted string', offset + i) + break + } + const esc = raw[i] + switch (esc) { + case 'n': + result += '\n' + break + case 't': + result += '\t' + break + case 'r': + result += '\r' + break + case '"': + result += '"' + break + case '\\': + result += '\\' + break + case '/': + result += '/' + break + default: + result += esc + } + } else if (ch === '"') { + // End of string + const end = offset + i + 1 + return new Scalar(result, [offset, end, lineEnd]) + } else { + result += ch + } + i++ + } + // Unterminated + this.addError('Unterminated double-quoted string', offset) + return new Scalar(result, [offset, offset + raw.length, lineEnd]) + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + private skipEmpty() { + while (this.pos < this.lines.length && this.lines[this.pos].content === '') { + this.pos++ + } + } + + /** + * Check if content looks like a map entry: `key: value` or `key:`. + * Must not start with "- ". + * The colon must be followed by a space or end of string. + */ + private isMapEntry(content: string): boolean { + // Find colon that is followed by space or EOL, not inside quotes + const colonIdx = this.findMapColon(content) + return colonIdx >= 0 + } + + /** + * Find the colon index for a map entry. + * Skip colons inside double-quoted strings. + */ + private findMapColon(content: string): number { + let inQuote = false + let escaped = false + for (let i = 0; i < content.length; i++) { + const ch = content[i] + if (escaped) { + escaped = false + continue + } + if (ch === '\\' && inQuote) { + escaped = true + continue + } + if (ch === '"') { + inQuote = !inQuote + continue + } + if (!inQuote && ch === ':') { + // Colon must be followed by space or EOL + if (i + 1 >= content.length || content[i + 1] === ' ') { + return i + } + } + } + return -1 + } + + private splitMapEntry( + content: string, + baseOffset: number + ): { + key: string + valueStr: string + colonOffset: number + valueOffset: number + } { + const colonIdx = this.findMapColon(content) + const keyRaw = content.slice(0, colonIdx) + const key = keyRaw.trim() + const colonOffset = baseOffset + colonIdx + const afterColon = content.slice(colonIdx + 1) + const valueStr = afterColon.trimStart() + const valueOffset = + colonOffset + 1 + (afterColon.length - afterColon.trimStart().length) + + // Handle quoted keys + if (key.startsWith('"') && key.endsWith('"')) { + return { + key: key.slice(1, -1), + valueStr, + colonOffset, + valueOffset, + } + } + + return { key, valueStr, colonOffset, valueOffset } + } + + private addError(message: string, offset: number) { + this.errors.push(new YAMLError(message, [offset, offset + 1])) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isNumeric(str: string): boolean { + if (str === '' || str === '-' || str === '+') return false + // Allow optional leading sign, digits, optional decimal, optional exponent + return /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(str) +} diff --git a/test/aria.test.ts b/test/aria.test.ts index f1aeb8b..814cc95 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -9,16 +9,11 @@ import { generateAriaTree, matchAriaTree, - parseAriaTemplate as parseAriaTemplateOriginal, + parseAriaTemplate, renderAriaTree, renderAriaTemplate, } from '../src/aria' import { describe, expect, test, vi } from 'vitest' -import * as yaml from 'yaml' - -function parseAriaTemplate(text: string) { - return parseAriaTemplateOriginal(yaml, text) -} function capture(html: string) { document.body.innerHTML = html @@ -2470,15 +2465,9 @@ describe('parseAriaTemplate', () => { not a list item - link `) - expect(t).toThrowErrorMatchingInlineSnapshot(` - [Error: Unexpected scalar at node end at line 5, column 7: - - - not a list item - - link - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ] - `) + expect(t).toThrowErrorMatchingInlineSnapshot( + `[Error: Unexpected scalar at node end]` + ) }) test('empty input', () => { @@ -2491,40 +2480,26 @@ describe('parseAriaTemplate', () => { test('throws on invalid role entry', () => { expect(() => parseAriaTemplate('- !@#')).toThrowErrorMatchingInlineSnapshot( ` - [Error: Unexpected end of input when expecting role: - + [Error: Unexpected input: + !@# ^ ] ` ) }) - // Playwright: page-aria-snapshot.spec.ts "should support multiline text" (| syntax) - test('YAML block scalar (| multiline)', () => { - const t = parseAriaTemplate(` + // Block scalars (|) are not supported by the minimal YAML parser. + test('YAML block scalar (| multiline) is not supported', () => { + expect(() => + parseAriaTemplate(` - paragraph: | Line one Line two `) - expect(t).toMatchInlineSnapshot(` - { - "children": [ - { - "kind": "text", - "text": { - "normalized": "Line one Line two", - "raw": "Line one - Line two - ", - }, - }, - ], - "kind": "role", - "name": undefined, - "role": "paragraph", - } - `) + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Bad indentation of a sequence entry]` + ) }) // Playwright: to-match-aria-snapshot.spec.ts "should report error in YAML keys" diff --git a/vitest.config.ts b/vitest.config.ts index 4922797..6b895a6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,15 +3,34 @@ import { playwright } from '@vitest/browser-playwright' export default defineConfig({ test: { - dir: './test', - browser: { - enabled: true, - provider: playwright(), - instances: [ - { - browser: 'chromium', + projects: [ + { + test: { + name: 'browser', + dir: './test', + browser: { + enabled: true, + provider: playwright(), + instances: [ + { + browser: 'chromium', + }, + { + browser: 'firefox', + }, + { + browser: 'webkit', + }, + ], + }, }, - ], - }, + }, + { + test: { + name: 'unit', + dir: './src', + }, + }, + ], }, })