diff --git a/packages/@rnw-scripts/accessibility-diagnostics/AccessibilityDiagnostics.sln b/packages/@rnw-scripts/accessibility-diagnostics/AccessibilityDiagnostics.sln
new file mode 100644
index 00000000000..ff4ae783ee3
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/AccessibilityDiagnostics.sln
@@ -0,0 +1,71 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.ReactNative.AccessibilityDiagnostics.Core", "src\Microsoft.ReactNative.AccessibilityDiagnostics.Core\Microsoft.ReactNative.AccessibilityDiagnostics.Core.csproj", "{C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.ReactNative.AccessibilityDiagnostics.Cli", "src\Microsoft.ReactNative.AccessibilityDiagnostics.Cli\Microsoft.ReactNative.AccessibilityDiagnostics.Cli.csproj", "{7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests", "tests\Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests\Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests.csproj", "{49FE0304-FDB3-4CF3-9AEE-47D811C192B2}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Debug|x64.Build.0 = Debug|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Debug|x86.Build.0 = Debug|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Release|x64.ActiveCfg = Release|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Release|x64.Build.0 = Release|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Release|x86.ActiveCfg = Release|Any CPU
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD}.Release|x86.Build.0 = Release|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Debug|x64.Build.0 = Debug|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Debug|x86.Build.0 = Debug|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Release|x64.ActiveCfg = Release|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Release|x64.Build.0 = Release|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Release|x86.ActiveCfg = Release|Any CPU
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8}.Release|x86.Build.0 = Release|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Debug|x64.Build.0 = Debug|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Debug|x86.Build.0 = Debug|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Release|x64.ActiveCfg = Release|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Release|x64.Build.0 = Release|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Release|x86.ActiveCfg = Release|Any CPU
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {C016F4D8-CDEB-40F3-A1DE-C48ADAD15DBD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {7FECBD50-E2EC-48A5-AA86-931B20F2E4F8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {49FE0304-FDB3-4CF3-9AEE-47D811C192B2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/Directory.Build.props b/packages/@rnw-scripts/accessibility-diagnostics/Directory.Build.props
new file mode 100644
index 00000000000..e55a9cdd1af
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ true
+ enable
+ latest
+ enable
+ false
+ true
+
+
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/Directory.Build.targets b/packages/@rnw-scripts/accessibility-diagnostics/Directory.Build.targets
new file mode 100644
index 00000000000..058246e4086
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/Directory.Build.targets
@@ -0,0 +1 @@
+
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/README.md b/packages/@rnw-scripts/accessibility-diagnostics/README.md
new file mode 100644
index 00000000000..0ad84780ab9
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/README.md
@@ -0,0 +1,130 @@
+# RNW Accessibility Diagnostics
+
+`rnw-a11y` captures the Windows UI Automation tree exposed by a running application and compares two captures. It
+helps React Native Windows engineers detect accessibility changes that are invisible in screenshots, including lost
+accessible names, control type changes, lost keyboard focusability, removed interactive controls, and tree-order
+changes.
+
+The tool is read-only. It does not invoke controls, change application state, or add code to the inspected
+application.
+
+## Prerequisites
+
+- Windows 10 version 22H2 or newer
+- .NET SDK 10
+- A running desktop application to inspect
+
+## Build and test
+
+From the repository root:
+
+```powershell
+yarn workspace @rnw-scripts/accessibility-diagnostics build
+yarn workspace @rnw-scripts/accessibility-diagnostics test
+```
+
+## Capture a snapshot
+
+Select exactly one target using a process ID, process name, native window handle, or exact window title:
+
+```powershell
+yarn workspace @rnw-scripts/accessibility-diagnostics rnw-a11y capture `
+ --process RNTesterApp-Fabric `
+ --output before.json
+```
+
+```powershell
+yarn workspace @rnw-scripts/accessibility-diagnostics rnw-a11y capture `
+ --hwnd 0x000A04BC `
+ --output after.json
+```
+
+Process-name selection supports hosted or packaged applications by finding the top-level window containing content
+from the requested process. If multiple windows match, use `--hwnd` to make the target explicit.
+
+Capture limits bound the generated snapshot and recursive processing:
+
+```powershell
+rnw-a11y capture --process MyApp --output app.json --max-depth 50 --max-nodes 5000
+```
+
+The defaults are a maximum depth of 100, a maximum of 10,000 nodes, and a 100 millisecond settling delay. The maximum
+supported depth is 200. A capture
+that reaches a limit or loses an element while reading is written with `captureStatus: "incomplete"` and returns exit
+code 4.
+
+## Compare snapshots
+
+Print differences to the console:
+
+```powershell
+yarn workspace @rnw-scripts/accessibility-diagnostics rnw-a11y diff before.json after.json
+```
+
+Create a Markdown report:
+
+```powershell
+yarn workspace @rnw-scripts/accessibility-diagnostics rnw-a11y diff before.json after.json `
+ --format markdown `
+ --output accessibility-diff.md `
+ --fail-on potential-regression
+```
+
+Available formats are `console`, `json`, and `markdown`.
+
+`--fail-on` controls when differences return exit code 1:
+
+| Value | Behavior |
+| --- | --- |
+| `none` | Always return success after a valid comparison. |
+| `potential-regression` | Fail only for changes classified as potential accessibility regressions. |
+| `any` | Fail for any difference. This is the default. |
+
+Incomplete snapshots are rejected by default because they can produce misleading results. Use `--allow-incomplete`
+only when intentionally investigating a partial capture.
+
+## Snapshot contents
+
+Snapshots use a versioned JSON schema and capture the Control View of the UI Automation tree. Each element can
+include:
+
+- accessible name and Automation ID;
+- control type, localized type, class name, and framework ID;
+- enabled, keyboard-focusable, and offscreen states;
+- help text and access key;
+- selection, toggle, and expand/collapse states;
+- ordered child elements.
+
+Runtime IDs, bounding rectangles, keyboard focus, and text-field values are excluded from comparisons because they
+are volatile or may contain sensitive information.
+
+## Matching and classification
+
+Elements are matched only within an already matched parent. The matcher uses unique Automation IDs first, then stable
+element characteristics and sequence alignment. This prevents one inserted element from turning the rest of the
+subtree into unrelated changes.
+
+The report keeps structural changes separate from risk classification. Potential regressions currently include:
+
+- changing an element's control type;
+- removing a keyboard-focusable or interactive element;
+- removing the accessible name from a focusable element;
+- making a previously focusable element unfocusable;
+- changing the relative order of existing sibling elements.
+
+These findings require engineering review. A clean diff does not prove that an application is fully accessible, and
+the tool does not replace keyboard or screen-reader testing.
+
+## Privacy
+
+Snapshots remain on the local machine unless the engineer explicitly shares or checks them in. Accessible names,
+help text, and item labels can contain application or user-provided content. Review snapshots before attaching them
+to bugs or pull requests.
+
+The tool intentionally does not read `ValuePattern` or text document contents.
+
+## Future focus-path recorder
+
+The snapshot model, target selection, cached UI Automation reader, serialization, and reporting layers are designed
+to support a future focus-path recorder. That extension can subscribe to UI Automation focus events and describe
+each focused element using the same property reader without changing the target application.
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/bin.js b/packages/@rnw-scripts/accessibility-diagnostics/bin.js
new file mode 100644
index 00000000000..1102414f05f
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/bin.js
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT License.
+ *
+ * @format
+ */
+
+'use strict';
+
+const {spawnSync} = require('child_process');
+const path = require('path');
+
+if (process.platform !== 'win32') {
+ console.error('rnw-a11y is only supported on Windows.');
+ process.exit(2);
+}
+
+const project = path.join(
+ __dirname,
+ 'src',
+ 'Microsoft.ReactNative.AccessibilityDiagnostics.Cli',
+ 'Microsoft.ReactNative.AccessibilityDiagnostics.Cli.csproj',
+);
+const result = spawnSync(
+ 'dotnet',
+ [
+ 'run',
+ '--project',
+ project,
+ '--configuration',
+ 'Release',
+ '--',
+ ...process.argv.slice(2),
+ ],
+ {stdio: 'inherit'},
+);
+
+if (result.error) {
+ console.error(`Failed to start rnw-a11y: ${result.error.message}`);
+ process.exit(3);
+}
+
+process.exit(result.status ?? 3);
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/package.json b/packages/@rnw-scripts/accessibility-diagnostics/package.json
new file mode 100644
index 00000000000..0b3470e00d4
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@rnw-scripts/accessibility-diagnostics",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/microsoft/react-native-windows",
+ "directory": "packages/@rnw-scripts/accessibility-diagnostics"
+ },
+ "scripts": {
+ "build": "node scripts/run-dotnet.js build",
+ "clean": "node scripts/run-dotnet.js clean",
+ "lint": "node --check bin.js && node --check scripts/run-dotnet.js && node scripts/run-dotnet.js lint",
+ "lint:fix": "node scripts/run-dotnet.js lint:fix",
+ "rnw-a11y": "node bin.js",
+ "test": "node scripts/run-dotnet.js test"
+ },
+ "bin": {
+ "rnw-a11y": "bin.js"
+ },
+ "engines": {
+ "node": ">= 22"
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/scripts/run-dotnet.js b/packages/@rnw-scripts/accessibility-diagnostics/scripts/run-dotnet.js
new file mode 100644
index 00000000000..ed61dd1e33d
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/scripts/run-dotnet.js
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT License.
+ *
+ * @format
+ */
+
+'use strict';
+
+const {spawnSync} = require('child_process');
+const path = require('path');
+
+const command = process.argv[2];
+const root = path.resolve(__dirname, '..');
+const solution = path.join(root, 'AccessibilityDiagnostics.sln');
+const tests = path.join(
+ root,
+ 'tests',
+ 'Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests',
+ 'Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests.csproj',
+);
+
+if (process.platform !== 'win32') {
+ console.log(`Skipping Windows-only accessibility diagnostics ${command}.`);
+ process.exit(0);
+}
+
+const commands = {
+ build: ['build', solution, '--configuration', 'Release'],
+ clean: ['clean', solution, '--configuration', 'Release'],
+ lint: ['format', solution, '--verify-no-changes'],
+ 'lint:fix': ['format', solution],
+ test: ['test', tests, '--configuration', 'Release'],
+};
+
+const args = commands[command];
+if (!args) {
+ console.error(`Unknown command: ${command}`);
+ process.exit(2);
+}
+
+const result = spawnSync('dotnet', args, {cwd: root, stdio: 'inherit'});
+if (result.error) {
+ console.error(`Failed to run dotnet: ${result.error.message}`);
+ process.exit(3);
+}
+
+process.exit(result.status ?? 3);
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/CliArguments.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/CliArguments.cs
new file mode 100644
index 00000000000..5bc87facc6f
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/CliArguments.cs
@@ -0,0 +1,306 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Globalization;
+using Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Cli;
+
+internal enum TargetSelectorKind
+{
+ ProcessId,
+ ProcessName,
+ WindowHandle,
+ WindowTitle,
+}
+
+internal enum ReportFormat
+{
+ Console,
+ Json,
+ Markdown,
+}
+
+internal sealed class CaptureCommand
+{
+ public required TargetSelectorKind SelectorKind { get; init; }
+
+ public required string SelectorValue { get; init; }
+
+ public required string OutputPath { get; init; }
+
+ public int MaxDepth { get; init; } = 100;
+
+ public int MaxNodes { get; init; } = 10_000;
+
+ public int SettleMilliseconds { get; init; } = 100;
+}
+
+internal sealed class DiffCommand
+{
+ public required string BeforePath { get; init; }
+
+ public required string AfterPath { get; init; }
+
+ public ReportFormat Format { get; init; } = ReportFormat.Console;
+
+ public string? OutputPath { get; init; }
+
+ public FailOn FailOn { get; init; } = FailOn.Any;
+
+ public bool AllowIncomplete { get; init; }
+}
+
+internal static class CliArguments
+{
+ private const int MaximumCaptureDepth = 200;
+
+ private static readonly HashSet CaptureOptions =
+ new(StringComparer.Ordinal)
+ {
+ "--pid",
+ "--process",
+ "--hwnd",
+ "--window-title",
+ "--output",
+ "--max-depth",
+ "--max-nodes",
+ "--settle-ms",
+ };
+
+ private static readonly HashSet DiffOptions =
+ new(StringComparer.Ordinal)
+ {
+ "--format",
+ "--output",
+ "--fail-on",
+ "--allow-incomplete",
+ };
+
+ public static CaptureCommand ParseCapture(string[] args)
+ {
+ var parsed = Parse(args, CaptureOptions, []);
+ if (parsed.Positionals.Count > 0)
+ {
+ throw new ArgumentException("The capture command does not accept positional arguments.");
+ }
+
+ var selectors = new[]
+ {
+ (Option: "--pid", Kind: TargetSelectorKind.ProcessId),
+ (Option: "--process", Kind: TargetSelectorKind.ProcessName),
+ (Option: "--hwnd", Kind: TargetSelectorKind.WindowHandle),
+ (Option: "--window-title", Kind: TargetSelectorKind.WindowTitle),
+ }.Where(selector => parsed.Values.ContainsKey(selector.Option)).ToArray();
+
+ if (selectors.Length != 1)
+ {
+ throw new ArgumentException(
+ "Specify exactly one target selector: --pid, --process, --hwnd, or --window-title.");
+ }
+
+ var maxDepth = ParsePositiveInt(parsed, "--max-depth", 100, MaximumCaptureDepth);
+ var maxNodes = ParsePositiveInt(parsed, "--max-nodes", 10_000);
+ var settleMilliseconds = ParseNonNegativeInt(parsed, "--settle-ms", 100);
+
+ return new CaptureCommand
+ {
+ SelectorKind = selectors[0].Kind,
+ SelectorValue = parsed.Values[selectors[0].Option],
+ OutputPath = GetRequired(parsed, "--output"),
+ MaxDepth = maxDepth,
+ MaxNodes = maxNodes,
+ SettleMilliseconds = settleMilliseconds,
+ };
+ }
+
+ public static DiffCommand ParseDiff(string[] args)
+ {
+ var parsed = Parse(args, DiffOptions, ["--allow-incomplete"]);
+ if (parsed.Positionals.Count != 2)
+ {
+ throw new ArgumentException("The diff command requires before and after snapshot paths.");
+ }
+
+ return new DiffCommand
+ {
+ BeforePath = parsed.Positionals[0],
+ AfterPath = parsed.Positionals[1],
+ Format = ParseEnum(parsed, "--format", ReportFormat.Console),
+ OutputPath = parsed.Values.GetValueOrDefault("--output"),
+ FailOn = ParseFailOn(parsed.Values.GetValueOrDefault("--fail-on")),
+ AllowIncomplete = parsed.Flags.Contains("--allow-incomplete"),
+ };
+ }
+
+ public static int ParseProcessId(string value)
+ {
+ return ParsePositiveInt(value, "--pid");
+ }
+
+ public static nint ParseWindowHandle(string value)
+ {
+ var style = NumberStyles.Integer;
+ if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ {
+ value = value[2..];
+ style = NumberStyles.HexNumber;
+ }
+
+ if (!long.TryParse(value, style, CultureInfo.InvariantCulture, out var handle) || handle <= 0)
+ {
+ throw new ArgumentException("--hwnd must be a positive decimal or hexadecimal window handle.");
+ }
+
+ return (nint)handle;
+ }
+
+ private static ParsedArguments Parse(
+ IReadOnlyList args,
+ HashSet allowedOptions,
+ HashSet flagOptions)
+ {
+ var result = new ParsedArguments();
+ for (var index = 0; index < args.Count; index++)
+ {
+ var value = args[index];
+ if (!value.StartsWith("--", StringComparison.Ordinal))
+ {
+ result.Positionals.Add(value);
+ continue;
+ }
+
+ if (!allowedOptions.Contains(value))
+ {
+ throw new ArgumentException($"Unknown option '{value}'.");
+ }
+
+ if (flagOptions.Contains(value))
+ {
+ if (!result.Flags.Add(value))
+ {
+ throw new ArgumentException($"Option '{value}' was specified more than once.");
+ }
+
+ continue;
+ }
+
+ if (result.Values.ContainsKey(value))
+ {
+ throw new ArgumentException($"Option '{value}' was specified more than once.");
+ }
+
+ if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"Option '{value}' requires a value.");
+ }
+
+ result.Values[value] = args[++index];
+ }
+
+ return result;
+ }
+
+ private static string GetRequired(ParsedArguments parsed, string option)
+ {
+ if (!parsed.Values.TryGetValue(option, out var value) || string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException($"Option '{option}' is required.");
+ }
+
+ return value;
+ }
+
+ private static int ParsePositiveInt(
+ ParsedArguments parsed,
+ string option,
+ int defaultValue,
+ int? maximumValue = null)
+ {
+ var parsedValue = parsed.Values.TryGetValue(option, out var value)
+ ? ParsePositiveInt(value, option)
+ : defaultValue;
+
+ if (maximumValue is not null && parsedValue > maximumValue)
+ {
+ throw new ArgumentException($"{option} cannot exceed {maximumValue}.");
+ }
+
+ return parsedValue;
+ }
+
+ private static int ParsePositiveInt(string value, string option)
+ {
+ if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
+ {
+ throw new ArgumentException($"{option} must be a positive integer.");
+ }
+
+ return parsed;
+ }
+
+ private static int ParseNonNegativeInt(ParsedArguments parsed, string option, int defaultValue)
+ {
+ if (!parsed.Values.TryGetValue(option, out var value))
+ {
+ return defaultValue;
+ }
+
+ if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedValue) ||
+ parsedValue < 0)
+ {
+ throw new ArgumentException($"{option} must be a non-negative integer.");
+ }
+
+ return parsedValue;
+ }
+
+ private static T ParseEnum(ParsedArguments parsed, string option, T defaultValue)
+ where T : struct, Enum
+ {
+ if (!parsed.Values.TryGetValue(option, out var value))
+ {
+ return defaultValue;
+ }
+
+ var matchingName = Enum.GetNames()
+ .SingleOrDefault(name => string.Equals(name, value, StringComparison.OrdinalIgnoreCase));
+ if (matchingName is null)
+ {
+ throw new ArgumentException(
+ $"{option} must be one of: {string.Join(", ", Enum.GetNames().Select(name => ToKebabCase(name)))}.");
+ }
+
+ return Enum.Parse(matchingName);
+ }
+
+ private static FailOn ParseFailOn(string? value)
+ {
+ return value?.ToLowerInvariant() switch
+ {
+ null => FailOn.Any,
+ "none" => FailOn.None,
+ "potential-regression" => FailOn.PotentialRegression,
+ "any" => FailOn.Any,
+ _ => throw new ArgumentException("--fail-on must be one of: none, potential-regression, any."),
+ };
+ }
+
+ private static string ToKebabCase(string value)
+ {
+ return string.Concat(
+ value.Select((character, index) =>
+ char.IsUpper(character) && index > 0
+ ? $"-{char.ToLowerInvariant(character)}"
+ : char.ToLowerInvariant(character).ToString()));
+ }
+
+ private sealed class ParsedArguments
+ {
+ public Dictionary Values { get; } = new(StringComparer.Ordinal);
+
+ public HashSet Flags { get; } = new(StringComparer.Ordinal);
+
+ public List Positionals { get; } = [];
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Microsoft.ReactNative.AccessibilityDiagnostics.Cli.csproj b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Microsoft.ReactNative.AccessibilityDiagnostics.Cli.csproj
new file mode 100644
index 00000000000..1c8d7bc1cbc
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Microsoft.ReactNative.AccessibilityDiagnostics.Cli.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ Exe
+ net10.0-windows10.0.22621.0
+ true
+
+
+
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Program.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Program.cs
new file mode 100644
index 00000000000..11630de74cf
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Program.cs
@@ -0,0 +1,171 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+using Microsoft.ReactNative.AccessibilityDiagnostics.Cli.Windows;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Cli;
+
+internal static class Program
+{
+ private const int Success = 0;
+ private const int DifferencesFound = 1;
+ private const int UsageError = 2;
+ private const int OperationError = 3;
+ private const int IncompleteCapture = 4;
+
+ [MTAThread]
+ public static async Task Main(string[] args)
+ {
+ Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+
+ if (args.Length == 0 || args.Contains("--help", StringComparer.Ordinal))
+ {
+ PrintUsage();
+ return args.Length == 0 ? UsageError : Success;
+ }
+
+ try
+ {
+ return args[0].ToLowerInvariant() switch
+ {
+ "capture" => await RunCaptureAsync(CliArguments.ParseCapture(args[1..])),
+ "diff" => await RunDiffAsync(CliArguments.ParseDiff(args[1..])),
+ _ => throw new ArgumentException($"Unknown command '{args[0]}'."),
+ };
+ }
+ catch (ArgumentException exception)
+ {
+ Console.Error.WriteLine($"Error: {OutputSanitizer.Sanitize(exception.Message)}");
+ Console.Error.WriteLine("Run 'rnw-a11y --help' for usage.");
+ return UsageError;
+ }
+ catch (SnapshotFormatException exception)
+ {
+ Console.Error.WriteLine($"Snapshot error: {OutputSanitizer.Sanitize(exception.Message)}");
+ return OperationError;
+ }
+ catch (AutomationCaptureException exception)
+ {
+ Console.Error.WriteLine($"Capture error: {OutputSanitizer.Sanitize(exception.Message)}");
+ return OperationError;
+ }
+ catch (IOException exception)
+ {
+ Console.Error.WriteLine($"File error: {OutputSanitizer.Sanitize(exception.Message)}");
+ return OperationError;
+ }
+ catch (UnauthorizedAccessException exception)
+ {
+ Console.Error.WriteLine($"Access error: {OutputSanitizer.Sanitize(exception.Message)}");
+ return OperationError;
+ }
+ catch (COMException exception)
+ {
+ Console.Error.WriteLine(
+ $"Windows UI Automation error: {OutputSanitizer.Sanitize(exception.Message)}");
+ return OperationError;
+ }
+ }
+
+ private static async Task RunCaptureAsync(CaptureCommand command)
+ {
+ var collector = new WindowsAutomationSnapshotCollector();
+ var snapshot = collector.Capture(command);
+ await SnapshotSerializer.WriteAsync(command.OutputPath, snapshot);
+
+ Console.WriteLine(
+ $"Captured {CountNodes(snapshot.Root)} element(s) from " +
+ $"'{OutputSanitizer.Sanitize(snapshot.Target.WindowTitle ?? "")}' to " +
+ $"{OutputSanitizer.Sanitize(command.OutputPath)}.");
+
+ foreach (var warning in snapshot.Warnings)
+ {
+ Console.Error.WriteLine($"Warning: {OutputSanitizer.Sanitize(warning)}");
+ }
+
+ return snapshot.CaptureStatus == CaptureStatus.Complete ? Success : IncompleteCapture;
+ }
+
+ private static async Task RunDiffAsync(DiffCommand command)
+ {
+ var before = await SnapshotSerializer.ReadSnapshotAsync(command.BeforePath);
+ var after = await SnapshotSerializer.ReadSnapshotAsync(command.AfterPath);
+ var diff = new SnapshotDiffer().Compare(
+ before,
+ after,
+ command.BeforePath,
+ command.AfterPath,
+ command.AllowIncomplete);
+
+ var report = command.Format switch
+ {
+ ReportFormat.Console => DiffReporters.RenderConsole(diff),
+ ReportFormat.Json => SnapshotSerializer.Serialize(diff),
+ ReportFormat.Markdown => DiffReporters.RenderMarkdown(diff),
+ _ => throw new ArgumentOutOfRangeException(nameof(command.Format)),
+ };
+
+ if (command.OutputPath is null)
+ {
+ Console.WriteLine(report);
+ }
+ else
+ {
+ await WriteReportAsync(command.OutputPath, report);
+ Console.WriteLine(
+ $"Wrote {diff.Changes.Count} change(s) to {OutputSanitizer.Sanitize(command.OutputPath)}; " +
+ $"{diff.Changes.Count(change => change.Impact == ChangeImpact.PotentialRegression)} " +
+ "potential regression(s).");
+ }
+
+ return SnapshotDiffer.ShouldFail(diff, command.FailOn) ? DifferencesFound : Success;
+ }
+
+ private static async Task WriteReportAsync(string path, string report)
+ {
+ var directory = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.WriteAllTextAsync(
+ path,
+ report.Replace("\r\n", "\n", StringComparison.Ordinal) + "\n",
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ }
+
+ private static int CountNodes(AccessibilityNode node)
+ {
+ return 1 + node.Children.Sum(CountNodes);
+ }
+
+ private static void PrintUsage()
+ {
+ Console.WriteLine(
+ """
+ RNW Accessibility Diagnostics
+
+ Capture a Windows UI Automation tree:
+ rnw-a11y capture (--pid | --process | --hwnd | --window-title )
+ --output
+ [--max-depth ] [--max-nodes ] [--settle-ms ]
+
+ Compare two snapshots:
+ rnw-a11y diff
+ [--format console|json|markdown] [--output ]
+ [--fail-on none|potential-regression|any] [--allow-incomplete]
+
+ Exit codes:
+ 0 Successful operation
+ 1 Differences matched the --fail-on policy
+ 2 Invalid command-line arguments
+ 3 Capture, snapshot, or file operation failed
+ 4 Capture completed with truncation or transient UIA warnings
+ """);
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/AutomationCaptureException.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/AutomationCaptureException.cs
new file mode 100644
index 00000000000..a04b0be0f6b
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/AutomationCaptureException.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Cli.Windows;
+
+internal sealed class AutomationCaptureException : Exception
+{
+ public AutomationCaptureException(string message)
+ : base(message)
+ {
+ }
+
+ public AutomationCaptureException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/WindowSelector.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/WindowSelector.cs
new file mode 100644
index 00000000000..43940299177
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/WindowSelector.cs
@@ -0,0 +1,196 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.IO;
+using System.Windows.Automation;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Cli.Windows;
+
+internal sealed record WindowSelection(
+ AutomationElement Root,
+ string? RequestedProcessName,
+ int? RequestedProcessId);
+
+internal static class WindowSelector
+{
+ public static WindowSelection Select(CaptureCommand command)
+ {
+ return command.SelectorKind switch
+ {
+ TargetSelectorKind.ProcessId => SelectByProcessIds(
+ [CliArguments.ParseProcessId(command.SelectorValue)],
+ requestedProcessName: null),
+ TargetSelectorKind.ProcessName => SelectByProcessName(command.SelectorValue),
+ TargetSelectorKind.WindowHandle => SelectByWindowHandle(command.SelectorValue),
+ TargetSelectorKind.WindowTitle => SelectByWindowTitle(command.SelectorValue),
+ _ => throw new ArgumentOutOfRangeException(nameof(command.SelectorKind)),
+ };
+ }
+
+ private static WindowSelection SelectByWindowHandle(string value)
+ {
+ var handle = CliArguments.ParseWindowHandle(value);
+ try
+ {
+ return new WindowSelection(AutomationElement.FromHandle(handle), null, null);
+ }
+ catch (ElementNotAvailableException exception)
+ {
+ throw new AutomationCaptureException(
+ $"No accessible window exists for handle 0x{handle:X}.",
+ exception);
+ }
+ }
+
+ private static WindowSelection SelectByProcessName(string processName)
+ {
+ var fileName = Path.GetFileName(processName);
+ var normalizedName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
+ ? fileName[..^4]
+ : fileName;
+ var processes = Process.GetProcessesByName(normalizedName);
+ if (processes.Length == 0)
+ {
+ throw new AutomationCaptureException($"No running process named '{normalizedName}' was found.");
+ }
+
+ try
+ {
+ return SelectByProcessIds(processes.Select(process => process.Id).ToArray(), normalizedName);
+ }
+ finally
+ {
+ foreach (var process in processes)
+ {
+ process.Dispose();
+ }
+ }
+ }
+
+ private static WindowSelection SelectByProcessIds(IReadOnlyCollection processIds, string? requestedProcessName)
+ {
+ var windows = GetTopLevelWindows();
+ var directMatches = windows
+ .Where(window => TryGetProcessId(window, out var processId) && processIds.Contains(processId))
+ .ToArray();
+
+ var matches = directMatches.Length > 0
+ ? directMatches
+ : windows.Where(window => ContainsProcess(window, processIds)).ToArray();
+
+ if (matches.Length == 0)
+ {
+ throw new AutomationCaptureException(
+ $"No top-level window was found for process ID(s) {string.Join(", ", processIds)}. " +
+ "Use --hwnd when the application is hosted by another process.");
+ }
+
+ if (matches.Length > 1)
+ {
+ throw new AutomationCaptureException(
+ $"Multiple top-level windows matched process ID(s) {string.Join(", ", processIds)}: " +
+ $"{string.Join(", ", matches.Select(DescribeWindow))}. Use --hwnd to select one window.");
+ }
+
+ return new WindowSelection(
+ matches[0],
+ requestedProcessName,
+ processIds.Count == 1 ? processIds.Single() : null);
+ }
+
+ private static WindowSelection SelectByWindowTitle(string title)
+ {
+ var matches = GetTopLevelWindows()
+ .Where(window => string.Equals(TryGetName(window), title, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ if (matches.Length == 0)
+ {
+ throw new AutomationCaptureException($"No top-level window titled '{title}' was found.");
+ }
+
+ if (matches.Length > 1)
+ {
+ throw new AutomationCaptureException(
+ $"Multiple top-level windows titled '{title}' were found. Use --hwnd to select one window.");
+ }
+
+ return new WindowSelection(matches[0], null, null);
+ }
+
+ private static AutomationElement[] GetTopLevelWindows()
+ {
+ return AutomationElement.RootElement
+ .FindAll(TreeScope.Children, Condition.TrueCondition)
+ .Cast()
+ .ToArray();
+ }
+
+ private static bool ContainsProcess(AutomationElement window, IReadOnlyCollection processIds)
+ {
+ foreach (var processId in processIds)
+ {
+ var condition = new PropertyCondition(AutomationElement.ProcessIdProperty, processId);
+ try
+ {
+ if (window.FindFirst(TreeScope.Descendants, condition) is not null)
+ {
+ return true;
+ }
+ }
+ catch (ElementNotAvailableException)
+ {
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ private static string DescribeWindow(AutomationElement window)
+ {
+ var handle = TryGetNativeWindowHandle(window, out var nativeWindowHandle) ? nativeWindowHandle : 0;
+ return $"'{TryGetName(window) ?? ""}' (0x{handle:X})";
+ }
+
+ private static string? TryGetName(AutomationElement element)
+ {
+ try
+ {
+ return element.Current.Name;
+ }
+ catch (ElementNotAvailableException)
+ {
+ return null;
+ }
+ }
+
+ private static bool TryGetProcessId(AutomationElement element, out int processId)
+ {
+ try
+ {
+ processId = element.Current.ProcessId;
+ return true;
+ }
+ catch (ElementNotAvailableException)
+ {
+ processId = 0;
+ return false;
+ }
+ }
+
+ private static bool TryGetNativeWindowHandle(AutomationElement element, out int nativeWindowHandle)
+ {
+ try
+ {
+ nativeWindowHandle = element.Current.NativeWindowHandle;
+ return true;
+ }
+ catch (ElementNotAvailableException)
+ {
+ nativeWindowHandle = 0;
+ return false;
+ }
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/WindowsAutomationSnapshotCollector.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/WindowsAutomationSnapshotCollector.cs
new file mode 100644
index 00000000000..4ddcdf0f288
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Cli/Windows/WindowsAutomationSnapshotCollector.cs
@@ -0,0 +1,352 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Runtime.InteropServices;
+using System.Windows.Automation;
+using Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Cli.Windows;
+
+internal sealed class WindowsAutomationSnapshotCollector
+{
+ private static readonly AutomationProperty[] CachedProperties =
+ [
+ AutomationElement.AccessKeyProperty,
+ AutomationElement.AutomationIdProperty,
+ AutomationElement.ClassNameProperty,
+ AutomationElement.ControlTypeProperty,
+ AutomationElement.FrameworkIdProperty,
+ AutomationElement.HelpTextProperty,
+ AutomationElement.IsEnabledProperty,
+ AutomationElement.IsKeyboardFocusableProperty,
+ AutomationElement.IsOffscreenProperty,
+ AutomationElement.LocalizedControlTypeProperty,
+ AutomationElement.NameProperty,
+ AutomationElement.NativeWindowHandleProperty,
+ AutomationElement.ProcessIdProperty,
+ ExpandCollapsePattern.ExpandCollapseStateProperty,
+ SelectionItemPattern.IsSelectedProperty,
+ TogglePattern.ToggleStateProperty,
+ ];
+
+ private static readonly AutomationPattern[] CachedPatterns =
+ [
+ ExpandCollapsePattern.Pattern,
+ SelectionItemPattern.Pattern,
+ TogglePattern.Pattern,
+ ];
+
+ public AccessibilitySnapshot Capture(CaptureCommand command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+
+ var selection = WindowSelector.Select(command);
+ var target = ReadTarget(selection);
+
+ if (command.SettleMilliseconds > 0)
+ {
+ Thread.Sleep(command.SettleMilliseconds);
+ }
+
+ var cacheRequest = CreateCacheRequest();
+ AutomationElement cachedRoot;
+ try
+ {
+ cachedRoot = selection.Root.GetUpdatedCache(cacheRequest);
+ }
+ catch (ElementNotAvailableException exception)
+ {
+ throw new AutomationCaptureException("The selected window closed before it could be captured.", exception);
+ }
+ catch (InvalidOperationException exception)
+ {
+ throw new AutomationCaptureException(
+ "Windows UI Automation could not cache the selected window.",
+ exception);
+ }
+
+ var context = new CaptureContext(command.MaxDepth, command.MaxNodes);
+ var root = CaptureNode(cachedRoot, depth: 0, context, TreeWalker.ControlViewWalker, cacheRequest)
+ ?? throw new AutomationCaptureException("The selected window became unavailable during capture.");
+
+ return new AccessibilitySnapshot
+ {
+ CaptureStatus = context.IsIncomplete ? CaptureStatus.Incomplete : CaptureStatus.Complete,
+ Warnings = context.Warnings,
+ CapturedAtUtc = DateTimeOffset.UtcNow,
+ Target = target,
+ Options = new SnapshotOptions
+ {
+ MaxDepth = command.MaxDepth,
+ MaxNodes = command.MaxNodes,
+ View = "control",
+ },
+ Root = root,
+ };
+ }
+
+ private static CacheRequest CreateCacheRequest()
+ {
+ var request = new CacheRequest
+ {
+ AutomationElementMode = AutomationElementMode.Full,
+ TreeFilter = Automation.ControlViewCondition,
+ TreeScope = TreeScope.Element,
+ };
+
+ foreach (var property in CachedProperties)
+ {
+ request.Add(property);
+ }
+
+ foreach (var pattern in CachedPatterns)
+ {
+ request.Add(pattern);
+ }
+
+ return request;
+ }
+
+ private static SnapshotTarget ReadTarget(WindowSelection selection)
+ {
+ try
+ {
+ return new SnapshotTarget
+ {
+ RequestedProcessName = selection.RequestedProcessName,
+ RequestedProcessId = selection.RequestedProcessId,
+ WindowProcessId = selection.Root.Current.ProcessId,
+ NativeWindowHandle = selection.Root.Current.NativeWindowHandle,
+ WindowTitle = Clean(selection.Root.Current.Name),
+ };
+ }
+ catch (ElementNotAvailableException exception)
+ {
+ throw new AutomationCaptureException("The selected window is no longer available.", exception);
+ }
+ }
+
+ private static AccessibilityNode? CaptureNode(
+ AutomationElement element,
+ int depth,
+ CaptureContext context,
+ TreeWalker walker,
+ CacheRequest cacheRequest)
+ {
+ if (context.NodeCount >= context.MaxNodes)
+ {
+ context.MarkIncomplete($"Capture stopped after reaching the {context.MaxNodes} node limit.");
+ return null;
+ }
+
+ try
+ {
+ context.NodeCount++;
+ var children = new List();
+
+ if (depth >= context.MaxDepth)
+ {
+ if (TryGetFirstChild(element, depth, context, walker, cacheRequest) is not null)
+ {
+ context.MarkIncomplete($"Capture stopped below depth {context.MaxDepth}.");
+ }
+ }
+ else
+ {
+ var child = TryGetFirstChild(element, depth, context, walker, cacheRequest);
+ while (child is not null)
+ {
+ var capturedChild = CaptureNode(child, depth + 1, context, walker, cacheRequest);
+ if (capturedChild is not null)
+ {
+ children.Add(capturedChild);
+ }
+
+ if (capturedChild is null && context.NodeCount >= context.MaxNodes)
+ {
+ break;
+ }
+
+ child = TryGetNextSibling(child, depth + 1, context, walker, cacheRequest);
+ }
+ }
+
+ return new AccessibilityNode
+ {
+ AutomationId = ReadString(element, AutomationElement.AutomationIdProperty),
+ Name = ReadString(element, AutomationElement.NameProperty),
+ ControlType = ReadControlType(element),
+ LocalizedControlType = ReadString(element, AutomationElement.LocalizedControlTypeProperty),
+ ClassName = ReadString(element, AutomationElement.ClassNameProperty),
+ FrameworkId = ReadString(element, AutomationElement.FrameworkIdProperty),
+ IsEnabled = ReadBoolean(element, AutomationElement.IsEnabledProperty, defaultValue: true),
+ IsKeyboardFocusable = ReadBoolean(
+ element,
+ AutomationElement.IsKeyboardFocusableProperty,
+ defaultValue: false),
+ IsOffscreen = ReadBoolean(element, AutomationElement.IsOffscreenProperty, defaultValue: false),
+ HelpText = ReadString(element, AutomationElement.HelpTextProperty),
+ AccessKey = ReadString(element, AutomationElement.AccessKeyProperty),
+ IsSelected = ReadIsSelected(element),
+ ToggleState = ReadToggleState(element),
+ ExpandCollapseState = ReadExpandCollapseState(element),
+ Children = children,
+ };
+ }
+ catch (ElementNotAvailableException)
+ {
+ context.MarkIncomplete($"An element at depth {depth} disappeared during capture.");
+ return null;
+ }
+ catch (InvalidOperationException exception)
+ {
+ context.MarkIncomplete(
+ $"An element at depth {depth} did not expose all cached properties: {exception.Message}");
+ return null;
+ }
+ catch (COMException exception)
+ {
+ context.MarkIncomplete($"Windows UI Automation skipped an element at depth {depth}: {exception.Message}");
+ return null;
+ }
+ }
+
+ private static AutomationElement? TryGetFirstChild(
+ AutomationElement element,
+ int depth,
+ CaptureContext context,
+ TreeWalker walker,
+ CacheRequest cacheRequest)
+ {
+ try
+ {
+ return walker.GetFirstChild(element, cacheRequest);
+ }
+ catch (ElementNotAvailableException)
+ {
+ context.MarkIncomplete($"Could not enumerate children for an element at depth {depth}.");
+ return null;
+ }
+ catch (InvalidOperationException exception)
+ {
+ context.MarkIncomplete($"Could not cache children at depth {depth}: {exception.Message}");
+ return null;
+ }
+ catch (COMException exception)
+ {
+ context.MarkIncomplete(
+ $"Windows UI Automation could not enumerate children at depth {depth}: {exception.Message}");
+ return null;
+ }
+ }
+
+ private static AutomationElement? TryGetNextSibling(
+ AutomationElement element,
+ int depth,
+ CaptureContext context,
+ TreeWalker walker,
+ CacheRequest cacheRequest)
+ {
+ try
+ {
+ return walker.GetNextSibling(element, cacheRequest);
+ }
+ catch (ElementNotAvailableException)
+ {
+ context.MarkIncomplete($"A sibling element disappeared at depth {depth}.");
+ return null;
+ }
+ catch (InvalidOperationException exception)
+ {
+ context.MarkIncomplete($"Could not cache a sibling at depth {depth}: {exception.Message}");
+ return null;
+ }
+ catch (COMException exception)
+ {
+ context.MarkIncomplete(
+ $"Windows UI Automation could not enumerate a sibling at depth {depth}: {exception.Message}");
+ return null;
+ }
+ }
+
+ private static string ReadControlType(AutomationElement element)
+ {
+ var value = element.GetCachedPropertyValue(AutomationElement.ControlTypeProperty, ignoreDefaultValue: true);
+ if (value is not ControlType controlType)
+ {
+ return "Unknown";
+ }
+
+ const string prefix = "ControlType.";
+ return controlType.ProgrammaticName.StartsWith(prefix, StringComparison.Ordinal)
+ ? controlType.ProgrammaticName[prefix.Length..]
+ : controlType.ProgrammaticName;
+ }
+
+ private static string? ReadString(AutomationElement element, AutomationProperty property)
+ {
+ var value = element.GetCachedPropertyValue(property, ignoreDefaultValue: true);
+ return ReferenceEquals(value, AutomationElement.NotSupported) ? null : Clean(value as string);
+ }
+
+ private static bool ReadBoolean(AutomationElement element, AutomationProperty property, bool defaultValue)
+ {
+ var value = element.GetCachedPropertyValue(property, ignoreDefaultValue: true);
+ return value is bool boolean ? boolean : defaultValue;
+ }
+
+ private static bool? ReadIsSelected(AutomationElement element)
+ {
+ return element.TryGetCachedPattern(SelectionItemPattern.Pattern, out var value) &&
+ value is SelectionItemPattern pattern
+ ? pattern.Cached.IsSelected
+ : null;
+ }
+
+ private static string? ReadToggleState(AutomationElement element)
+ {
+ return element.TryGetCachedPattern(TogglePattern.Pattern, out var value) && value is TogglePattern pattern
+ ? pattern.Cached.ToggleState.ToString()
+ : null;
+ }
+
+ private static string? ReadExpandCollapseState(AutomationElement element)
+ {
+ return element.TryGetCachedPattern(ExpandCollapsePattern.Pattern, out var value) &&
+ value is ExpandCollapsePattern pattern
+ ? pattern.Cached.ExpandCollapseState.ToString()
+ : null;
+ }
+
+ private static string? Clean(string? value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+
+ private sealed class CaptureContext
+ {
+ private readonly HashSet _warnings = new(StringComparer.Ordinal);
+
+ public CaptureContext(int maxDepth, int maxNodes)
+ {
+ MaxDepth = maxDepth;
+ MaxNodes = maxNodes;
+ }
+
+ public int MaxDepth { get; }
+
+ public int MaxNodes { get; }
+
+ public int NodeCount { get; set; }
+
+ public bool IsIncomplete { get; private set; }
+
+ public List Warnings => _warnings.OrderBy(warning => warning, StringComparer.Ordinal).ToList();
+
+ public void MarkIncomplete(string warning)
+ {
+ IsIncomplete = true;
+ _warnings.Add(warning);
+ }
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/DiffReporters.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/DiffReporters.cs
new file mode 100644
index 00000000000..e4e095221cf
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/DiffReporters.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+public static class DiffReporters
+{
+ public static string RenderConsole(SnapshotDiff diff)
+ {
+ var builder = new StringBuilder();
+ AppendSummary(builder, diff);
+
+ foreach (var warning in diff.Warnings)
+ {
+ builder.AppendLine($"WARNING: {OutputSanitizer.Sanitize(warning)}");
+ }
+
+ foreach (var change in diff.Changes)
+ {
+ var marker = change.Impact == ChangeImpact.PotentialRegression ? "!" : "-";
+ builder.AppendLine(
+ $"{marker} [{change.Kind}] {OutputSanitizer.Sanitize(change.Path)}: " +
+ $"{OutputSanitizer.Sanitize(change.Description)}");
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+
+ public static string RenderMarkdown(SnapshotDiff diff)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("# Accessibility tree diff");
+ builder.AppendLine();
+ builder.AppendLine($"- Before: `{EscapeMarkdown(diff.BeforeSource)}`");
+ builder.AppendLine($"- After: `{EscapeMarkdown(diff.AfterSource)}`");
+ builder.AppendLine($"- Changes: {diff.Changes.Count}");
+ var potentialRegressionCount =
+ diff.Changes.Count(change => change.Impact == ChangeImpact.PotentialRegression);
+ builder.AppendLine($"- Potential regressions: {potentialRegressionCount}");
+
+ if (diff.Warnings.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("## Warnings");
+ builder.AppendLine();
+ foreach (var warning in diff.Warnings)
+ {
+ builder.AppendLine($"- {EscapeMarkdown(warning)}");
+ }
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("## Changes");
+ builder.AppendLine();
+
+ if (diff.Changes.Count == 0)
+ {
+ builder.AppendLine("No accessibility tree differences were found.");
+ return builder.ToString().TrimEnd();
+ }
+
+ builder.AppendLine("| Impact | Kind | Path | Change |");
+ builder.AppendLine("| --- | --- | --- | --- |");
+ foreach (var change in diff.Changes)
+ {
+ builder.AppendLine(
+ $"| {change.Impact} | {change.Kind} | `{EscapeMarkdown(change.Path)}` | " +
+ $"{EscapeMarkdown(change.Description)} |");
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+
+ private static void AppendSummary(StringBuilder builder, SnapshotDiff diff)
+ {
+ builder.AppendLine(
+ $"Compared {OutputSanitizer.Sanitize(diff.BeforeSource)} -> " +
+ $"{OutputSanitizer.Sanitize(diff.AfterSource)}");
+ var potentialRegressionCount =
+ diff.Changes.Count(change => change.Impact == ChangeImpact.PotentialRegression);
+ builder.AppendLine(
+ $"Found {diff.Changes.Count} change(s), {potentialRegressionCount} potential regression(s).");
+ }
+
+ private static string EscapeMarkdown(string value)
+ {
+ return OutputSanitizer.Sanitize(value)
+ .Replace("|", "\\|", StringComparison.Ordinal)
+ .Replace("\r", string.Empty, StringComparison.Ordinal)
+ .Replace("\n", "
", StringComparison.Ordinal)
+ .Replace("`", "\\`", StringComparison.Ordinal);
+ }
+
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/Microsoft.ReactNative.AccessibilityDiagnostics.Core.csproj b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/Microsoft.ReactNative.AccessibilityDiagnostics.Core.csproj
new file mode 100644
index 00000000000..4340f623963
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/Microsoft.ReactNative.AccessibilityDiagnostics.Core.csproj
@@ -0,0 +1,7 @@
+
+
+
+ net10.0
+
+
+
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/Models.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/Models.cs
new file mode 100644
index 00000000000..5ff98eb2794
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/Models.cs
@@ -0,0 +1,148 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+public enum CaptureStatus
+{
+ Complete,
+ Incomplete,
+}
+
+public enum ChangeKind
+{
+ Added,
+ Removed,
+ Modified,
+}
+
+public enum ChangeImpact
+{
+ Informational,
+ PotentialRegression,
+}
+
+public enum FailOn
+{
+ None,
+ PotentialRegression,
+ Any,
+}
+
+public sealed class AccessibilitySnapshot
+{
+ public const int CurrentSchemaVersion = 1;
+
+ [JsonPropertyOrder(0)]
+ public int SchemaVersion { get; init; } = CurrentSchemaVersion;
+
+ [JsonPropertyOrder(1)]
+ public CaptureStatus CaptureStatus { get; init; } = CaptureStatus.Complete;
+
+ [JsonPropertyOrder(2)]
+ public List Warnings { get; init; } = [];
+
+ [JsonPropertyOrder(3)]
+ public DateTimeOffset CapturedAtUtc { get; init; }
+
+ [JsonPropertyOrder(4)]
+ public required SnapshotTarget Target { get; init; }
+
+ [JsonPropertyOrder(5)]
+ public required SnapshotOptions Options { get; init; }
+
+ [JsonPropertyOrder(6)]
+ public required AccessibilityNode Root { get; init; }
+}
+
+public sealed class SnapshotTarget
+{
+ public string? RequestedProcessName { get; init; }
+
+ public int? RequestedProcessId { get; init; }
+
+ public int WindowProcessId { get; init; }
+
+ public int NativeWindowHandle { get; init; }
+
+ public string? WindowTitle { get; init; }
+}
+
+public sealed class SnapshotOptions
+{
+ public string View { get; init; } = "control";
+
+ public int MaxDepth { get; init; }
+
+ public int MaxNodes { get; init; }
+}
+
+public sealed class AccessibilityNode
+{
+ public string? AutomationId { get; init; }
+
+ public string? Name { get; init; }
+
+ public required string ControlType { get; init; }
+
+ public string? LocalizedControlType { get; init; }
+
+ public string? ClassName { get; init; }
+
+ public string? FrameworkId { get; init; }
+
+ public bool IsEnabled { get; init; }
+
+ public bool IsKeyboardFocusable { get; init; }
+
+ public bool IsOffscreen { get; init; }
+
+ public string? HelpText { get; init; }
+
+ public string? AccessKey { get; init; }
+
+ public bool? IsSelected { get; init; }
+
+ public string? ToggleState { get; init; }
+
+ public string? ExpandCollapseState { get; init; }
+
+ public List Children { get; init; } = [];
+}
+
+public sealed class SnapshotDiff
+{
+ public required string BeforeSource { get; init; }
+
+ public required string AfterSource { get; init; }
+
+ public List Warnings { get; init; } = [];
+
+ public List Changes { get; init; } = [];
+
+ [JsonIgnore]
+ public bool HasDifferences => Changes.Count > 0;
+
+ [JsonIgnore]
+ public bool HasPotentialRegressions =>
+ Changes.Any(change => change.Impact == ChangeImpact.PotentialRegression);
+}
+
+public sealed class AccessibilityChange
+{
+ public required ChangeKind Kind { get; init; }
+
+ public required ChangeImpact Impact { get; init; }
+
+ public required string Path { get; init; }
+
+ public string? Property { get; init; }
+
+ public string? Before { get; init; }
+
+ public string? After { get; init; }
+
+ public required string Description { get; init; }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/OutputSanitizer.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/OutputSanitizer.cs
new file mode 100644
index 00000000000..3a603896e22
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/OutputSanitizer.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+public static class OutputSanitizer
+{
+ public static string Sanitize(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ var builder = new StringBuilder(value.Length);
+ foreach (var character in value)
+ {
+ if (char.IsControl(character))
+ {
+ builder.Append($"\\u{(int)character:X4}");
+ }
+ else
+ {
+ builder.Append(character);
+ }
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotDiffer.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotDiffer.cs
new file mode 100644
index 00000000000..71f8f987d61
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotDiffer.cs
@@ -0,0 +1,500 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+public sealed class SnapshotDiffer
+{
+ private const long MaximumLcsCells = 1_000_000;
+
+ private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
+
+ private static readonly HashSet InteractiveControlTypes =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Button",
+ "CheckBox",
+ "ComboBox",
+ "Edit",
+ "Hyperlink",
+ "ListItem",
+ "MenuItem",
+ "RadioButton",
+ "Slider",
+ "Spinner",
+ "TabItem",
+ "TreeItem",
+ };
+
+ public SnapshotDiff Compare(
+ AccessibilitySnapshot before,
+ AccessibilitySnapshot after,
+ string beforeSource,
+ string afterSource,
+ bool allowIncomplete = false)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ if (!allowIncomplete &&
+ (before.CaptureStatus == CaptureStatus.Incomplete || after.CaptureStatus == CaptureStatus.Incomplete))
+ {
+ throw new SnapshotFormatException(
+ "Cannot compare incomplete snapshots. Re-capture the application or pass --allow-incomplete.");
+ }
+
+ var changes = new List();
+ CompareNode(before.Root, after.Root, "$", changes);
+
+ var warnings = new List();
+ warnings.AddRange(before.Warnings.Select(warning => $"Before: {warning}"));
+ warnings.AddRange(after.Warnings.Select(warning => $"After: {warning}"));
+
+ return new SnapshotDiff
+ {
+ BeforeSource = beforeSource,
+ AfterSource = afterSource,
+ Warnings = warnings,
+ Changes = changes,
+ };
+ }
+
+ public static bool ShouldFail(SnapshotDiff diff, FailOn failOn)
+ {
+ return failOn switch
+ {
+ FailOn.None => false,
+ FailOn.PotentialRegression => diff.HasPotentialRegressions,
+ FailOn.Any => diff.HasDifferences,
+ _ => throw new ArgumentOutOfRangeException(nameof(failOn), failOn, "Unknown failure policy."),
+ };
+ }
+
+ private static void CompareNode(
+ AccessibilityNode before,
+ AccessibilityNode after,
+ string path,
+ List changes)
+ {
+ CompareProperty(path, "automationId", before.AutomationId, after.AutomationId, before, after, changes);
+ CompareProperty(path, "name", before.Name, after.Name, before, after, changes);
+ CompareProperty(path, "controlType", before.ControlType, after.ControlType, before, after, changes);
+ CompareProperty(
+ path,
+ "localizedControlType",
+ before.LocalizedControlType,
+ after.LocalizedControlType,
+ before,
+ after,
+ changes);
+ CompareProperty(path, "className", before.ClassName, after.ClassName, before, after, changes);
+ CompareProperty(path, "frameworkId", before.FrameworkId, after.FrameworkId, before, after, changes);
+ CompareProperty(path, "isEnabled", before.IsEnabled, after.IsEnabled, before, after, changes);
+ CompareProperty(
+ path,
+ "isKeyboardFocusable",
+ before.IsKeyboardFocusable,
+ after.IsKeyboardFocusable,
+ before,
+ after,
+ changes);
+ CompareProperty(path, "isOffscreen", before.IsOffscreen, after.IsOffscreen, before, after, changes);
+ CompareProperty(path, "helpText", before.HelpText, after.HelpText, before, after, changes);
+ CompareProperty(path, "accessKey", before.AccessKey, after.AccessKey, before, after, changes);
+ CompareProperty(path, "isSelected", before.IsSelected, after.IsSelected, before, after, changes);
+ CompareProperty(path, "toggleState", before.ToggleState, after.ToggleState, before, after, changes);
+ CompareProperty(
+ path,
+ "expandCollapseState",
+ before.ExpandCollapseState,
+ after.ExpandCollapseState,
+ before,
+ after,
+ changes);
+
+ CompareChildren(before.Children, after.Children, path, changes);
+ }
+
+ private static void CompareChildren(
+ IReadOnlyList before,
+ IReadOnlyList after,
+ string parentPath,
+ List changes)
+ {
+ var matches = MatchChildren(before, after);
+ var matchedBefore = matches.Select(match => match.BeforeIndex).ToHashSet();
+ var matchedAfter = matches.Select(match => match.AfterIndex).ToHashSet();
+
+ var afterOrder = matches.OrderBy(match => match.BeforeIndex).Select(match => match.AfterIndex).ToArray();
+ if (!afterOrder.SequenceEqual(afterOrder.OrderBy(index => index)))
+ {
+ changes.Add(new AccessibilityChange
+ {
+ Kind = ChangeKind.Modified,
+ Impact = ChangeImpact.PotentialRegression,
+ Path = parentPath,
+ Property = "childrenOrder",
+ Description = "The relative order of existing child elements changed.",
+ });
+ }
+
+ foreach (var match in matches.OrderBy(match => match.BeforeIndex))
+ {
+ var segment = CreatePathSegment(before[match.BeforeIndex], match.BeforeIndex);
+ CompareNode(before[match.BeforeIndex], after[match.AfterIndex], $"{parentPath}/{segment}", changes);
+ }
+
+ for (var index = 0; index < before.Count; index++)
+ {
+ if (matchedBefore.Contains(index))
+ {
+ continue;
+ }
+
+ var node = before[index];
+ changes.Add(new AccessibilityChange
+ {
+ Kind = ChangeKind.Removed,
+ Impact = ContainsInteractiveElement(node)
+ ? ChangeImpact.PotentialRegression
+ : ChangeImpact.Informational,
+ Path = $"{parentPath}/{CreatePathSegment(node, index)}",
+ Before = DescribeNode(node),
+ Description = $"Removed {DescribeNode(node)}.",
+ });
+ }
+
+ for (var index = 0; index < after.Count; index++)
+ {
+ if (matchedAfter.Contains(index))
+ {
+ continue;
+ }
+
+ var node = after[index];
+ changes.Add(new AccessibilityChange
+ {
+ Kind = ChangeKind.Added,
+ Impact = ChangeImpact.Informational,
+ Path = $"{parentPath}/{CreatePathSegment(node, index)}",
+ After = DescribeNode(node),
+ Description = $"Added {DescribeNode(node)}.",
+ });
+ }
+ }
+
+ private static List MatchChildren(
+ IReadOnlyList before,
+ IReadOnlyList after)
+ {
+ var matches = new List();
+ var usedBefore = new HashSet();
+ var usedAfter = new HashSet();
+
+ MatchUniqueAutomationIds(before, after, matches, usedBefore, usedAfter);
+ MatchByStrongIdentity(before, after, matches, usedBefore, usedAfter);
+ MatchByWeakIdentity(before, after, matches, usedBefore, usedAfter);
+
+ return matches;
+ }
+
+ private static void MatchUniqueAutomationIds(
+ IReadOnlyList before,
+ IReadOnlyList after,
+ List matches,
+ HashSet usedBefore,
+ HashSet usedAfter)
+ {
+ var beforeIds = GetUniqueAutomationIds(before);
+ var afterIds = GetUniqueAutomationIds(after);
+
+ foreach (var pair in beforeIds)
+ {
+ if (!afterIds.TryGetValue(pair.Key, out var afterIndex))
+ {
+ continue;
+ }
+
+ matches.Add(new NodeMatch(pair.Value, afterIndex));
+ usedBefore.Add(pair.Value);
+ usedAfter.Add(afterIndex);
+ }
+ }
+
+ private static Dictionary GetUniqueAutomationIds(IReadOnlyList nodes)
+ {
+ return nodes
+ .Select((node, index) => new { Id = Normalize(node.AutomationId), Index = index })
+ .Where(item => !string.IsNullOrEmpty(item.Id))
+ .GroupBy(item => item.Id!, StringComparer.Ordinal)
+ .Where(group => group.Count() == 1)
+ .ToDictionary(group => group.Key, group => group.Single().Index, StringComparer.Ordinal);
+ }
+
+ private static void MatchByStrongIdentity(
+ IReadOnlyList before,
+ IReadOnlyList after,
+ List matches,
+ HashSet usedBefore,
+ HashSet usedAfter)
+ {
+ var beforeIndices = Enumerable.Range(0, before.Count).Where(index => !usedBefore.Contains(index)).ToArray();
+ var afterIndices = Enumerable.Range(0, after.Count).Where(index => !usedAfter.Contains(index)).ToArray();
+ var beforeIdentities = beforeIndices.Select(index => StrongIdentity(before[index])).ToArray();
+ var afterIdentities = afterIndices.Select(index => StrongIdentity(after[index])).ToArray();
+
+ if ((long)beforeIndices.Length * afterIndices.Length > MaximumLcsCells)
+ {
+ MatchStrongIdentityLinearly(
+ beforeIndices,
+ afterIndices,
+ beforeIdentities,
+ afterIdentities,
+ matches,
+ usedBefore,
+ usedAfter);
+ return;
+ }
+
+ var lengths = new int[beforeIndices.Length + 1, afterIndices.Length + 1];
+
+ for (var beforeOffset = beforeIndices.Length - 1; beforeOffset >= 0; beforeOffset--)
+ {
+ for (var afterOffset = afterIndices.Length - 1; afterOffset >= 0; afterOffset--)
+ {
+ if (beforeIdentities[beforeOffset] == afterIdentities[afterOffset])
+ {
+ lengths[beforeOffset, afterOffset] = lengths[beforeOffset + 1, afterOffset + 1] + 1;
+ }
+ else
+ {
+ lengths[beforeOffset, afterOffset] = Math.Max(
+ lengths[beforeOffset + 1, afterOffset],
+ lengths[beforeOffset, afterOffset + 1]);
+ }
+ }
+ }
+
+ var beforePosition = 0;
+ var afterPosition = 0;
+ while (beforePosition < beforeIndices.Length && afterPosition < afterIndices.Length)
+ {
+ var beforeIndex = beforeIndices[beforePosition];
+ var afterIndex = afterIndices[afterPosition];
+ if (beforeIdentities[beforePosition] == afterIdentities[afterPosition])
+ {
+ matches.Add(new NodeMatch(beforeIndex, afterIndex));
+ usedBefore.Add(beforeIndex);
+ usedAfter.Add(afterIndex);
+ beforePosition++;
+ afterPosition++;
+ }
+ else if (lengths[beforePosition + 1, afterPosition] >= lengths[beforePosition, afterPosition + 1])
+ {
+ beforePosition++;
+ }
+ else
+ {
+ afterPosition++;
+ }
+ }
+ }
+
+ private static void MatchStrongIdentityLinearly(
+ IReadOnlyList beforeIndices,
+ IReadOnlyList afterIndices,
+ IReadOnlyList beforeIdentities,
+ IReadOnlyList afterIdentities,
+ List matches,
+ HashSet usedBefore,
+ HashSet usedAfter)
+ {
+ var afterByIdentity = new Dictionary>(StringComparer.Ordinal);
+ for (var index = 0; index < afterIndices.Count; index++)
+ {
+ if (!afterByIdentity.TryGetValue(afterIdentities[index], out var queue))
+ {
+ queue = new Queue();
+ afterByIdentity.Add(afterIdentities[index], queue);
+ }
+
+ queue.Enqueue(afterIndices[index]);
+ }
+
+ for (var index = 0; index < beforeIndices.Count; index++)
+ {
+ if (!afterByIdentity.TryGetValue(beforeIdentities[index], out var queue) || queue.Count == 0)
+ {
+ continue;
+ }
+
+ var beforeIndex = beforeIndices[index];
+ var afterIndex = queue.Dequeue();
+ matches.Add(new NodeMatch(beforeIndex, afterIndex));
+ usedBefore.Add(beforeIndex);
+ usedAfter.Add(afterIndex);
+ }
+ }
+
+ private static void MatchByWeakIdentity(
+ IReadOnlyList before,
+ IReadOnlyList after,
+ List matches,
+ HashSet usedBefore,
+ HashSet usedAfter)
+ {
+ foreach (var beforeIndex in Enumerable.Range(0, before.Count).Where(index => !usedBefore.Contains(index)))
+ {
+ var candidates = Enumerable.Range(0, after.Count)
+ .Where(index => !usedAfter.Contains(index))
+ .Where(index => WeakIdentity(before[beforeIndex]) == WeakIdentity(after[index]))
+ .Where(index => !WouldCrossExistingMatch(beforeIndex, index, matches))
+ .OrderBy(index => Math.Abs(index - beforeIndex))
+ .ToArray();
+
+ if (candidates.Length == 0)
+ {
+ continue;
+ }
+
+ var afterIndex = candidates[0];
+ matches.Add(new NodeMatch(beforeIndex, afterIndex));
+ usedBefore.Add(beforeIndex);
+ usedAfter.Add(afterIndex);
+ }
+ }
+
+ private static bool WouldCrossExistingMatch(
+ int beforeIndex,
+ int afterIndex,
+ IReadOnlyList matches)
+ {
+ return matches.Any(match =>
+ (match.BeforeIndex < beforeIndex && match.AfterIndex > afterIndex) ||
+ (match.BeforeIndex > beforeIndex && match.AfterIndex < afterIndex));
+ }
+
+ private static void CompareProperty(
+ string path,
+ string property,
+ T beforeValue,
+ T afterValue,
+ AccessibilityNode beforeNode,
+ AccessibilityNode afterNode,
+ List changes)
+ {
+ var before = FormatValue(beforeValue);
+ var after = FormatValue(afterValue);
+ if (before == after)
+ {
+ return;
+ }
+
+ changes.Add(new AccessibilityChange
+ {
+ Kind = ChangeKind.Modified,
+ Impact = GetImpact(property, before, after, beforeNode, afterNode),
+ Path = path,
+ Property = property,
+ Before = before,
+ After = after,
+ Description = $"{property} changed from {DisplayValue(before)} to {DisplayValue(after)}.",
+ });
+ }
+
+ private static ChangeImpact GetImpact(
+ string property,
+ string? before,
+ string? after,
+ AccessibilityNode beforeNode,
+ AccessibilityNode afterNode)
+ {
+ if (property == "controlType")
+ {
+ return ChangeImpact.PotentialRegression;
+ }
+
+ if (property == "isKeyboardFocusable" && before == "true" && after == "false")
+ {
+ return ChangeImpact.PotentialRegression;
+ }
+
+ if (property == "name" &&
+ !string.IsNullOrEmpty(before) &&
+ string.IsNullOrEmpty(after) &&
+ (beforeNode.IsKeyboardFocusable || afterNode.IsKeyboardFocusable))
+ {
+ return ChangeImpact.PotentialRegression;
+ }
+
+ return ChangeImpact.Informational;
+ }
+
+ private static bool ContainsInteractiveElement(AccessibilityNode node)
+ {
+ return node.IsKeyboardFocusable ||
+ InteractiveControlTypes.Contains(node.ControlType) ||
+ node.Children.Any(ContainsInteractiveElement);
+ }
+
+ private static string StrongIdentity(AccessibilityNode node)
+ {
+ return string.Join(
+ '\u001f',
+ Normalize(node.ControlType),
+ Normalize(node.Name),
+ Normalize(node.ClassName));
+ }
+
+ private static string WeakIdentity(AccessibilityNode node)
+ {
+ return string.Join('\u001f', Normalize(node.ControlType), Normalize(node.ClassName));
+ }
+
+ private static string CreatePathSegment(AccessibilityNode node, int index)
+ {
+ var identity = !string.IsNullOrEmpty(Normalize(node.AutomationId))
+ ? $"#{Normalize(node.AutomationId)}"
+ : $"{Normalize(node.ControlType) ?? "Unknown"}[{index}]";
+ return identity.Replace("/", "\\/", StringComparison.Ordinal);
+ }
+
+ private static string DescribeNode(AccessibilityNode node)
+ {
+ return string.IsNullOrEmpty(Normalize(node.Name))
+ ? node.ControlType
+ : $"{node.ControlType} \"{Normalize(node.Name)}\"";
+ }
+
+ private static string? FormatValue(T value)
+ {
+ return value switch
+ {
+ null => null,
+ string text => Normalize(text),
+ bool boolean => boolean ? "true" : "false",
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString(),
+ };
+ }
+
+ private static string DisplayValue(string? value)
+ {
+ return value is null ? "" : $"\"{value}\"";
+ }
+
+ private static string? Normalize(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ return Whitespace.Replace(value.Trim(), " ");
+ }
+
+ private sealed record NodeMatch(int BeforeIndex, int AfterIndex);
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotFormatException.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotFormatException.cs
new file mode 100644
index 00000000000..f44aaa38aa6
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotFormatException.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+public sealed class SnapshotFormatException : Exception
+{
+ public SnapshotFormatException(string message)
+ : base(message)
+ {
+ }
+
+ public SnapshotFormatException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotSerializer.cs b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotSerializer.cs
new file mode 100644
index 00000000000..62d88216357
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/src/Microsoft.ReactNative.AccessibilityDiagnostics.Core/SnapshotSerializer.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core;
+
+public static class SnapshotSerializer
+{
+ private static readonly UTF8Encoding Utf8WithoutBom = new(false);
+
+ public static JsonSerializerOptions Options { get; } = CreateOptions();
+
+ public static string Serialize(T value)
+ {
+ try
+ {
+ return JsonSerializer.Serialize(value, Options).Replace("\r\n", "\n", StringComparison.Ordinal);
+ }
+ catch (JsonException exception)
+ {
+ throw new SnapshotFormatException("The accessibility data could not be serialized.", exception);
+ }
+ }
+
+ public static AccessibilitySnapshot DeserializeSnapshot(string json)
+ {
+ AccessibilitySnapshot snapshot;
+ try
+ {
+ snapshot = JsonSerializer.Deserialize(json, Options)
+ ?? throw new SnapshotFormatException("The snapshot file is empty.");
+ }
+ catch (JsonException exception)
+ {
+ throw new SnapshotFormatException("The snapshot is not valid JSON.", exception);
+ }
+
+ if (snapshot.SchemaVersion != AccessibilitySnapshot.CurrentSchemaVersion)
+ {
+ throw new SnapshotFormatException(
+ $"Unsupported snapshot schema version {snapshot.SchemaVersion}. " +
+ $"Expected {AccessibilitySnapshot.CurrentSchemaVersion}.");
+ }
+
+ if (!Enum.IsDefined(snapshot.CaptureStatus))
+ {
+ throw new SnapshotFormatException($"Unknown capture status value {(int)snapshot.CaptureStatus}.");
+ }
+
+ Validate(snapshot);
+ return snapshot;
+ }
+
+ public static async Task WriteAsync(string path, T value, CancellationToken cancellationToken = default)
+ {
+ var directory = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.WriteAllTextAsync(path, Serialize(value) + "\n", Utf8WithoutBom, cancellationToken);
+ }
+
+ public static async Task ReadSnapshotAsync(
+ string path,
+ CancellationToken cancellationToken = default)
+ {
+ var json = await File.ReadAllTextAsync(path, Utf8WithoutBom, cancellationToken);
+ return DeserializeSnapshot(json);
+ }
+
+ private static JsonSerializerOptions CreateOptions()
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ MaxDepth = 512,
+ WriteIndented = true,
+ };
+ options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
+ return options;
+ }
+
+ private static void Validate(AccessibilitySnapshot snapshot)
+ {
+ if (snapshot.Target is null)
+ {
+ throw new SnapshotFormatException("The snapshot target is missing.");
+ }
+
+ if (snapshot.Options is null)
+ {
+ throw new SnapshotFormatException("The snapshot options are missing.");
+ }
+
+ if (snapshot.Warnings is null)
+ {
+ throw new SnapshotFormatException("The snapshot warning collection is missing.");
+ }
+
+ if (snapshot.Options.MaxDepth <= 0 || snapshot.Options.MaxNodes <= 0)
+ {
+ throw new SnapshotFormatException("Snapshot capture limits must be positive.");
+ }
+
+ if (snapshot.Root is null)
+ {
+ throw new SnapshotFormatException("The snapshot root element is missing.");
+ }
+
+ ValidateNode(snapshot.Root, "$");
+ }
+
+ private static void ValidateNode(AccessibilityNode node, string path)
+ {
+ if (string.IsNullOrWhiteSpace(node.ControlType))
+ {
+ throw new SnapshotFormatException($"The element at {path} has no control type.");
+ }
+
+ if (node.Children is null)
+ {
+ throw new SnapshotFormatException($"The element at {path} has no child collection.");
+ }
+
+ for (var index = 0; index < node.Children.Count; index++)
+ {
+ var child = node.Children[index];
+ if (child is null)
+ {
+ throw new SnapshotFormatException($"The element at {path} has a null child at index {index}.");
+ }
+
+ ValidateNode(child, $"{path}/children[{index}]");
+ }
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Fixtures/after.json b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Fixtures/after.json
new file mode 100644
index 00000000000..2a1c736457a
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Fixtures/after.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "captureStatus": "complete",
+ "warnings": [],
+ "capturedAtUtc": "2026-09-14T12:05:00+00:00",
+ "target": {
+ "requestedProcessName": "RNTesterApp-Fabric",
+ "requestedProcessId": 100,
+ "windowProcessId": 100,
+ "nativeWindowHandle": 1234,
+ "windowTitle": "RNTester"
+ },
+ "options": {
+ "view": "control",
+ "maxDepth": 100,
+ "maxNodes": 10000
+ },
+ "root": {
+ "automationId": null,
+ "name": "RNTester",
+ "controlType": "Window",
+ "localizedControlType": "window",
+ "className": "RNTesterWindow",
+ "frameworkId": "Win32",
+ "isEnabled": true,
+ "isKeyboardFocusable": true,
+ "isOffscreen": false,
+ "helpText": null,
+ "accessKey": null,
+ "isSelected": null,
+ "toggleState": null,
+ "expandCollapseState": null,
+ "children": [
+ {
+ "automationId": "saveButton",
+ "name": null,
+ "controlType": "Button",
+ "localizedControlType": "button",
+ "className": "Button",
+ "frameworkId": "ReactNative",
+ "isEnabled": true,
+ "isKeyboardFocusable": false,
+ "isOffscreen": false,
+ "helpText": null,
+ "accessKey": null,
+ "isSelected": null,
+ "toggleState": null,
+ "expandCollapseState": null,
+ "children": []
+ }
+ ]
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Fixtures/before.json b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Fixtures/before.json
new file mode 100644
index 00000000000..c9afc1878b6
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Fixtures/before.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "captureStatus": "complete",
+ "warnings": [],
+ "capturedAtUtc": "2026-09-14T12:00:00+00:00",
+ "target": {
+ "requestedProcessName": "RNTesterApp-Fabric",
+ "requestedProcessId": 100,
+ "windowProcessId": 100,
+ "nativeWindowHandle": 1234,
+ "windowTitle": "RNTester"
+ },
+ "options": {
+ "view": "control",
+ "maxDepth": 100,
+ "maxNodes": 10000
+ },
+ "root": {
+ "automationId": null,
+ "name": "RNTester",
+ "controlType": "Window",
+ "localizedControlType": "window",
+ "className": "RNTesterWindow",
+ "frameworkId": "Win32",
+ "isEnabled": true,
+ "isKeyboardFocusable": true,
+ "isOffscreen": false,
+ "helpText": null,
+ "accessKey": null,
+ "isSelected": null,
+ "toggleState": null,
+ "expandCollapseState": null,
+ "children": [
+ {
+ "automationId": "saveButton",
+ "name": "Save",
+ "controlType": "Button",
+ "localizedControlType": "button",
+ "className": "Button",
+ "frameworkId": "ReactNative",
+ "isEnabled": true,
+ "isKeyboardFocusable": true,
+ "isOffscreen": false,
+ "helpText": null,
+ "accessKey": null,
+ "isSelected": null,
+ "toggleState": null,
+ "expandCollapseState": null,
+ "children": []
+ }
+ ]
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/MSTestSettings.cs b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/MSTestSettings.cs
new file mode 100644
index 00000000000..7309c8ce88d
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/MSTestSettings.cs
@@ -0,0 +1,4 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests.csproj b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests.csproj
new file mode 100644
index 00000000000..67a2815c0e1
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/SnapshotDifferTests.cs b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/SnapshotDifferTests.cs
new file mode 100644
index 00000000000..7e317914edd
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/SnapshotDifferTests.cs
@@ -0,0 +1,228 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests;
+
+[TestClass]
+public sealed class SnapshotDifferTests
+{
+ [TestMethod]
+ public async Task DetectsAccessibleNameAndFocusabilityRegressions()
+ {
+ var before = await ReadFixtureAsync("before.json");
+ var after = await ReadFixtureAsync("after.json");
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before.json", "after.json");
+
+ Assert.AreEqual(2, diff.Changes.Count);
+ Assert.IsTrue(diff.Changes.All(change => change.Impact == ChangeImpact.PotentialRegression));
+ Assert.IsTrue(diff.Changes.Any(change => change.Property == "name"));
+ Assert.IsTrue(diff.Changes.Any(change => change.Property == "isKeyboardFocusable"));
+ Assert.IsTrue(SnapshotDiffer.ShouldFail(diff, FailOn.PotentialRegression));
+ }
+
+ [TestMethod]
+ public void InsertedSiblingDoesNotCascadeIntoUnrelatedChanges()
+ {
+ var before = CreateSnapshot(
+ Node("Window", children: [Node("Button", "First", "first"), Node("Button", "Second", "second")]));
+ var after = CreateSnapshot(
+ Node(
+ "Window",
+ children:
+ [
+ Node("Text", "New"),
+ Node("Button", "First", "first"),
+ Node("Button", "Second", "second"),
+ ]));
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after");
+
+ Assert.AreEqual(1, diff.Changes.Count);
+ Assert.AreEqual(ChangeKind.Added, diff.Changes[0].Kind);
+ Assert.AreEqual(ChangeImpact.Informational, diff.Changes[0].Impact);
+ }
+
+ [TestMethod]
+ public void ReorderedAutomationIdsProduceOneOrderChange()
+ {
+ var before = CreateSnapshot(
+ Node("Window", children: [Node("Button", "First", "first"), Node("Button", "Second", "second")]));
+ var after = CreateSnapshot(
+ Node("Window", children: [Node("Button", "Second", "second"), Node("Button", "First", "first")]));
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after");
+
+ Assert.AreEqual(1, diff.Changes.Count);
+ Assert.AreEqual("childrenOrder", diff.Changes[0].Property);
+ Assert.AreEqual(ChangeImpact.PotentialRegression, diff.Changes[0].Impact);
+ }
+
+ [TestMethod]
+ public void RenamedFocusableNodeIsMatchedByWeakIdentity()
+ {
+ var before = CreateSnapshot(Node("Window", children: [Node("Button", "Save", focusable: true)]));
+ var after = CreateSnapshot(Node("Window", children: [Node("Button", "Submit", focusable: true)]));
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after");
+
+ Assert.AreEqual(1, diff.Changes.Count);
+ Assert.AreEqual("name", diff.Changes[0].Property);
+ Assert.AreEqual(ChangeImpact.Informational, diff.Changes[0].Impact);
+ }
+
+ [TestMethod]
+ public void ReplacementAcrossExistingMatchDoesNotProduceOrderRegression()
+ {
+ var before = CreateSnapshot(
+ Node("Window", children: [Node("Text", "One"), Node("Text", "Two")]));
+ var after = CreateSnapshot(
+ Node("Window", children: [Node("Text", "Two"), Node("Text", "Three")]));
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after");
+
+ Assert.AreEqual(2, diff.Changes.Count);
+ Assert.IsTrue(diff.Changes.Any(change => change.Kind == ChangeKind.Removed && change.Before!.Contains("One")));
+ Assert.IsTrue(diff.Changes.Any(change => change.Kind == ChangeKind.Added && change.After!.Contains("Three")));
+ Assert.IsFalse(diff.Changes.Any(change => change.Property == "childrenOrder"));
+ Assert.IsFalse(diff.HasPotentialRegressions);
+ }
+
+ [TestMethod]
+ public void IncompleteSnapshotsAreRejectedByDefault()
+ {
+ var before = CreateSnapshot(
+ Node("Window"),
+ CaptureStatus.Incomplete,
+ ["Node limit reached."]);
+ var after = CreateSnapshot(Node("Window"));
+
+ Assert.ThrowsException(
+ () => new SnapshotDiffer().Compare(before, after, "before", "after"));
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after", allowIncomplete: true);
+ CollectionAssert.Contains(diff.Warnings, "Before: Node limit reached.");
+ }
+
+ [TestMethod]
+ public void RemovingContainerWithInteractiveDescendantIsPotentialRegression()
+ {
+ var before = CreateSnapshot(
+ Node(
+ "Window",
+ children:
+ [
+ Node(
+ "Group",
+ children: [Node("Button", "Save", focusable: true)]),
+ ]));
+ var after = CreateSnapshot(Node("Window"));
+
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after");
+
+ Assert.AreEqual(1, diff.Changes.Count);
+ Assert.AreEqual(ChangeKind.Removed, diff.Changes[0].Kind);
+ Assert.AreEqual(ChangeImpact.PotentialRegression, diff.Changes[0].Impact);
+ }
+
+ [TestMethod]
+ public void WideSiblingSetsUseBoundedMatching()
+ {
+ const int siblingCount = 1_500;
+ var beforeChildren = Enumerable.Range(0, siblingCount)
+ .Select(_ => Node("Text", "Repeated"))
+ .ToList();
+ var afterChildren = Enumerable.Range(0, siblingCount)
+ .Select(_ => Node("Text", "Repeated"))
+ .ToList();
+
+ var diff = new SnapshotDiffer().Compare(
+ CreateSnapshot(Node("Window", children: beforeChildren)),
+ CreateSnapshot(Node("Window", children: afterChildren)),
+ "before",
+ "after");
+
+ Assert.AreEqual(0, diff.Changes.Count);
+ }
+
+ [TestMethod]
+ public void MarkdownReportIncludesPotentialRegressionCount()
+ {
+ var before = CreateSnapshot(Node("Window", children: [Node("Button", "Save", "save", focusable: true)]));
+ var after = CreateSnapshot(Node("Window", children: [Node("Button", null, "save", focusable: true)]));
+ var diff = new SnapshotDiffer().Compare(before, after, "before.json", "after.json");
+
+ var report = DiffReporters.RenderMarkdown(diff);
+
+ StringAssert.Contains(report, "Potential regressions: 1");
+ StringAssert.Contains(report, "name changed");
+ }
+
+ [TestMethod]
+ public void ConsoleReportEscapesControlCharacters()
+ {
+ var before = CreateSnapshot(Node("Window", children: [Node("Button", "Save", "save")]));
+ var after = CreateSnapshot(Node("Window", children: [Node("Button", "\u001b[31mSave", "save")]));
+ var diff = new SnapshotDiffer().Compare(before, after, "before", "after");
+
+ var report = DiffReporters.RenderConsole(diff);
+
+ Assert.IsFalse(report.Contains('\u001b'));
+ StringAssert.Contains(report, "\\u001B");
+ }
+
+ [TestMethod]
+ public void OutputSanitizerEscapesControlCharacters()
+ {
+ Assert.AreEqual("title\\u001B[31m", OutputSanitizer.Sanitize("title\u001b[31m"));
+ }
+
+ private static async Task ReadFixtureAsync(string name)
+ {
+ return await SnapshotSerializer.ReadSnapshotAsync(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
+ }
+
+ private static AccessibilitySnapshot CreateSnapshot(
+ AccessibilityNode root,
+ CaptureStatus captureStatus = CaptureStatus.Complete,
+ List? warnings = null)
+ {
+ return new AccessibilitySnapshot
+ {
+ CaptureStatus = captureStatus,
+ Warnings = warnings ?? [],
+ CapturedAtUtc = new DateTimeOffset(2026, 9, 14, 12, 0, 0, TimeSpan.Zero),
+ Target = new SnapshotTarget
+ {
+ WindowProcessId = 100,
+ NativeWindowHandle = 1234,
+ WindowTitle = "Test",
+ },
+ Options = new SnapshotOptions
+ {
+ MaxDepth = 100,
+ MaxNodes = 10_000,
+ },
+ Root = root,
+ };
+ }
+
+ private static AccessibilityNode Node(
+ string controlType,
+ string? name = null,
+ string? automationId = null,
+ bool focusable = false,
+ List? children = null)
+ {
+ return new AccessibilityNode
+ {
+ AutomationId = automationId,
+ Name = name,
+ ControlType = controlType,
+ ClassName = controlType,
+ IsEnabled = true,
+ IsKeyboardFocusable = focusable,
+ Children = children ?? [],
+ };
+ }
+}
diff --git a/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/SnapshotSerializerTests.cs b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/SnapshotSerializerTests.cs
new file mode 100644
index 00000000000..95a4a42f375
--- /dev/null
+++ b/packages/@rnw-scripts/accessibility-diagnostics/tests/Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests/SnapshotSerializerTests.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.ReactNative.AccessibilityDiagnostics.Core.Tests;
+
+[TestClass]
+public sealed class SnapshotSerializerTests
+{
+ [TestMethod]
+ public async Task FixtureRoundTripsDeterministically()
+ {
+ var path = GetFixturePath("before.json");
+ var snapshot = await SnapshotSerializer.ReadSnapshotAsync(path);
+
+ var first = SnapshotSerializer.Serialize(snapshot);
+ var second = SnapshotSerializer.Serialize(SnapshotSerializer.DeserializeSnapshot(first));
+
+ Assert.AreEqual(first, second);
+ Assert.IsFalse(first.Contains("\r\n", StringComparison.Ordinal));
+ }
+
+ [TestMethod]
+ public void UnsupportedSchemaVersionIsRejected()
+ {
+ const string json =
+ """
+ {
+ "schemaVersion": 999,
+ "captureStatus": "complete",
+ "capturedAtUtc": "2026-09-14T12:00:00+00:00",
+ "target": {},
+ "options": {},
+ "root": { "controlType": "Window" }
+ }
+ """;
+
+ var exception = Assert.ThrowsException(
+ () => SnapshotSerializer.DeserializeSnapshot(json));
+
+ StringAssert.Contains(exception.Message, "Unsupported snapshot schema version 999");
+ }
+
+ [TestMethod]
+ public void NullRootIsRejected()
+ {
+ const string json =
+ """
+ {
+ "schemaVersion": 1,
+ "captureStatus": "complete",
+ "warnings": [],
+ "capturedAtUtc": "2026-09-14T12:00:00+00:00",
+ "target": {},
+ "options": { "maxDepth": 100, "maxNodes": 10000 },
+ "root": null
+ }
+ """;
+
+ var exception = Assert.ThrowsException(
+ () => SnapshotSerializer.DeserializeSnapshot(json));
+
+ StringAssert.Contains(exception.Message, "root element is missing");
+ }
+
+ [TestMethod]
+ public void NumericCaptureStatusIsRejected()
+ {
+ const string json =
+ """
+ {
+ "schemaVersion": 1,
+ "captureStatus": 999,
+ "warnings": [],
+ "capturedAtUtc": "2026-09-14T12:00:00+00:00",
+ "target": {},
+ "options": { "maxDepth": 100, "maxNodes": 10000 },
+ "root": { "controlType": "Window", "children": [] }
+ }
+ """;
+
+ Assert.ThrowsException(
+ () => SnapshotSerializer.DeserializeSnapshot(json));
+ }
+
+ [TestMethod]
+ public void DefaultCaptureDepthCanBeSerialized()
+ {
+ var root = new AccessibilityNode
+ {
+ ControlType = "Text",
+ };
+ var current = root;
+ for (var depth = 1; depth <= 100; depth++)
+ {
+ var child = new AccessibilityNode
+ {
+ ControlType = "Text",
+ };
+ current.Children.Add(child);
+ current = child;
+ }
+
+ var snapshot = new AccessibilitySnapshot
+ {
+ CapturedAtUtc = new DateTimeOffset(2026, 9, 14, 12, 0, 0, TimeSpan.Zero),
+ Target = new SnapshotTarget(),
+ Options = new SnapshotOptions
+ {
+ MaxDepth = 100,
+ MaxNodes = 10_000,
+ },
+ Root = root,
+ };
+
+ var json = SnapshotSerializer.Serialize(snapshot);
+ var roundTripped = SnapshotSerializer.DeserializeSnapshot(json);
+
+ Assert.AreEqual("Text", roundTripped.Root.ControlType);
+ }
+
+ private static string GetFixturePath(string name)
+ {
+ return Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 92d76b6c5ce..386621f3fff 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4728,6 +4728,14 @@ __metadata:
languageName: node
linkType: hard
+"@rnw-scripts/accessibility-diagnostics@workspace:packages/@rnw-scripts/accessibility-diagnostics":
+ version: 0.0.0-use.local
+ resolution: "@rnw-scripts/accessibility-diagnostics@workspace:packages/@rnw-scripts/accessibility-diagnostics"
+ bin:
+ rnw-a11y: bin.js
+ languageName: unknown
+ linkType: soft
+
"@rnw-scripts/babel-node-config@npm:2.3.3, @rnw-scripts/babel-node-config@workspace:packages/@rnw-scripts/babel-node-config":
version: 0.0.0-use.local
resolution: "@rnw-scripts/babel-node-config@workspace:packages/@rnw-scripts/babel-node-config"