Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import { isMoveControlMessage } from "./controlMessages";
const ACCESSIBILITY_REFRESH_MS = 1500;
const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500;
const FLUTTER_ACCESSIBILITY_REFRESH_MS = 1000;
const ACCESSIBILITY_BACKGROUND_REFRESH_MS = 3000;
const ANDROID_METADATA_REFRESH_MS = 1000;
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
Expand Down Expand Up @@ -1429,18 +1430,15 @@ export function AppShell({
);

useEffect(() => {
if (!hierarchyVisible) {
return;
}

const refreshMs =
accessibilityPreferredSource === "react-native" ||
accessibilitySource === "react-native"
const refreshMs = hierarchyVisible
? accessibilityPreferredSource === "react-native" ||
accessibilitySource === "react-native"
? REACT_NATIVE_ACCESSIBILITY_REFRESH_MS
: accessibilityPreferredSource === "flutter" ||
accessibilitySource === "flutter"
? FLUTTER_ACCESSIBILITY_REFRESH_MS
: ACCESSIBILITY_REFRESH_MS;
: ACCESSIBILITY_REFRESH_MS
: ACCESSIBILITY_BACKGROUND_REFRESH_MS;
let disposed = false;
let timeout: number | null = null;
const refreshLoop = async () => {
Expand Down
30 changes: 30 additions & 0 deletions client/src/app/uiState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,36 @@ describe("uiState", () => {
).toBe(false);
});

it("retains the current NativeScript tree during empty or fallback refreshes", () => {
expect(
shouldRetainAccessibilityTreeDuringRefresh(
"auto",
"nativescript",
"nativescript",
0,
3,
),
).toBe(true);
expect(
shouldRetainAccessibilityTreeDuringRefresh(
"nativescript",
"nativescript",
"native-ax",
12,
3,
),
).toBe(true);
expect(
shouldRetainAccessibilityTreeDuringRefresh(
"auto",
"nativescript",
"nativescript",
4,
3,
),
).toBe(false);
});

it("sanitizes persisted viewport state and falls back to defaults", () => {
const sanitized = sanitizePersistedUiState({
bundleIDValue: 123 as unknown as string,
Expand Down
12 changes: 11 additions & 1 deletion client/src/app/uiState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ export function shouldRetainAccessibilityTreeDuringRefresh(
}
const retainedSource =
currentPreference === "auto" ? currentSource : currentPreference;
if (retainedSource !== "flutter" && retainedSource !== "react-native") {
if (
!isAccessibilitySource(retainedSource) ||
!retainableRichAccessibilitySources.has(retainedSource)
) {
return false;
}
if (currentSource !== retainedSource) {
Expand All @@ -182,6 +185,13 @@ export function shouldRetainAccessibilityTreeDuringRefresh(
return snapshotSource !== retainedSource || nextRootCount === 0;
}

const retainableRichAccessibilitySources = new Set<AccessibilitySource>([
"nativescript",
"react-native",
"flutter",
"swiftui",
]);

export function isAccessibilitySource(
value: unknown,
): value is AccessibilitySource {
Expand Down
129 changes: 86 additions & 43 deletions client/src/features/accessibility/AccessibilityInspector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import type { CSSProperties, FormEvent, ReactNode } from "react";

import { sendInspectorRequest } from "../../api/simulators";
import type {
Expand Down Expand Up @@ -42,6 +42,7 @@ interface AccessibilityInspectorProps {
}

type InspectorTab = "console" | "inspector" | "performance";
type DetailValue = ReactNode;

export function AccessibilityInspector({
availableSources,
Expand Down Expand Up @@ -317,6 +318,7 @@ export function AccessibilityInspector({
const kind = accessibilityKind(item.node);
const label = hierarchyNodeLabel(item.node, kind);
const sourceBadge = sourceLocationBadgeText(item.node);
const sourceHref = sourceLocationHref(item.node);
const sourceTitle = sourceLocationText(item.node);
const chainBadge = compactedChainBadgeText(item.chain.length);
const chainPath = compactedChainPathText(item);
Expand Down Expand Up @@ -377,15 +379,25 @@ export function AccessibilityInspector({
{label ? (
<span className="hierarchy-node-text">{label}</span>
) : null}
{sourceBadge ? (
</button>
{sourceBadge ? (
sourceHref ? (
<a
className="hierarchy-node-source"
href={sourceHref}
title={sourceTitle}
>
{sourceBadge}
</a>
) : (
<span
className="hierarchy-node-source"
title={sourceTitle}
>
{sourceBadge}
</span>
) : null}
</button>
)
) : null}
</div>
);
})
Expand Down Expand Up @@ -500,46 +512,59 @@ function NodeDetails({
selectedSimulator: SimulatorMetadata | null;
}) {
const isAndroid = isAndroidSimulator(selectedSimulator);
const details = [
["Type", accessibilityKind(node)],
["Label", primaryAccessibilityText(node)],
["Source", sourceLocationText(node)],
const sourceText = sourceLocationText(node);
const sourceHref = sourceLocationHref(node);
const details = (
[
isAndroid ? "Resource ID" : "Identifier",
isAndroid
? (node.androidResourceId ?? "")
: accessibilityIdentifier(node),
],
["Inspector ID", node.inspectorId ?? ""],
["Module", node.moduleName ?? ""],
["NativeScript", nativeScriptDescription(node.nativeScript)],
["React Native", reactNativeDescription(node.reactNative)],
["Flutter", flutterDescription(node.flutter)],
[isAndroid ? "Android Class" : "UIKit Class", node.className ?? ""],
["Package", isAndroid ? (node.androidPackage ?? "") : ""],
["Last JS", lastUIKitScriptText(node)],
["Value", node.AXValue ?? ""],
["Role", node.role ?? ""],
["Role Description", node.role_description ?? ""],
["View Controller", objectClassName(node.viewController)],
["SwiftUI", swiftUIDescription(node.swiftUI)],
["Enabled", node.enabled == null ? "" : node.enabled ? "true" : "false"],
["Hidden", node.isHidden == null ? "" : node.isHidden ? "true" : "false"],
["Clickable", boolDetail(isAndroid, node.clickable)],
["Long Clickable", boolDetail(isAndroid, node.longClickable)],
["Focusable", boolDetail(isAndroid, node.focusable)],
["Focused", boolDetail(isAndroid, node.focused)],
["Scrollable", boolDetail(isAndroid, node.scrollable)],
["Checkable", boolDetail(isAndroid, node.checkable)],
["Checked", boolDetail(isAndroid, node.checked)],
["Selected", boolDetail(isAndroid, node.selected)],
["Password", boolDetail(isAndroid, node.password)],
["Alpha", node.alpha == null ? "" : String(round(node.alpha))],
["Frame", validFrame(node.frame) ? frameText(node.frame) : ""],
["PID", node.pid == null ? "" : String(node.pid)],
["Actions", node.custom_actions?.join(", ") ?? ""],
["Help", node.help ?? ""],
].filter(([, value]) => value);
["Type", accessibilityKind(node)],
["Label", primaryAccessibilityText(node)],
[
"Source",
sourceHref ? (
<a className="hierarchy-detail-link" href={sourceHref}>
{sourceText}
</a>
) : (
sourceText
),
],
[
isAndroid ? "Resource ID" : "Identifier",
isAndroid
? (node.androidResourceId ?? "")
: accessibilityIdentifier(node),
],
["Inspector ID", node.inspectorId ?? ""],
["Module", node.moduleName ?? ""],
["NativeScript", nativeScriptDescription(node.nativeScript)],
["React Native", reactNativeDescription(node.reactNative)],
["Flutter", flutterDescription(node.flutter)],
[isAndroid ? "Android Class" : "UIKit Class", node.className ?? ""],
["Package", isAndroid ? (node.androidPackage ?? "") : ""],
["Last JS", lastUIKitScriptText(node)],
["Value", node.AXValue ?? ""],
["Role", node.role ?? ""],
["Role Description", node.role_description ?? ""],
["View Controller", objectClassName(node.viewController)],
["SwiftUI", swiftUIDescription(node.swiftUI)],
["Enabled", node.enabled == null ? "" : node.enabled ? "true" : "false"],
["Hidden", node.isHidden == null ? "" : node.isHidden ? "true" : "false"],
["Clickable", boolDetail(isAndroid, node.clickable)],
["Long Clickable", boolDetail(isAndroid, node.longClickable)],
["Focusable", boolDetail(isAndroid, node.focusable)],
["Focused", boolDetail(isAndroid, node.focused)],
["Scrollable", boolDetail(isAndroid, node.scrollable)],
["Checkable", boolDetail(isAndroid, node.checkable)],
["Checked", boolDetail(isAndroid, node.checked)],
["Selected", boolDetail(isAndroid, node.selected)],
["Password", boolDetail(isAndroid, node.password)],
["Alpha", node.alpha == null ? "" : String(round(node.alpha))],
["Frame", validFrame(node.frame) ? frameText(node.frame) : ""],
["PID", node.pid == null ? "" : String(node.pid)],
["Actions", node.custom_actions?.join(", ") ?? ""],
["Help", node.help ?? ""],
] as Array<[string, DetailValue]>
).filter(([, value]) => value);

return (
<div className="hierarchy-details">
Expand Down Expand Up @@ -673,6 +698,24 @@ function sourceLocationText(node: AccessibilityNode): string {
return `${location.file}:${line}:${column}`;
}

function sourceLocationHref(node: AccessibilityNode): string {
const location = primarySourceLocation(node);
return location?.file ? fileUrl(location.file) : "";
}

function fileUrl(file: string): string {
if (file.startsWith("file://")) {
return file;
}
if (!file.startsWith("/")) {
return "";
}
return `file://${file
.split("/")
.map((part, index) => (index === 0 ? "" : encodeURIComponent(part)))
.join("/")}`;
}

function sourceLocationBadgeText(node: AccessibilityNode): string {
const location = primarySourceLocation(node);
if (!location?.file) {
Expand Down
26 changes: 26 additions & 0 deletions client/src/features/accessibility/AccessibilityOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";

import { accessibilityDomTagName } from "./AccessibilityOverlay";

describe("accessibilityDomTagName", () => {
it("uses source and component names for annotator-friendly custom tags", () => {
expect(
accessibilityDomTagName({
source: "nativescript",
type: "TabItem",
}),
).toBe("simdeck-tab-item");
expect(
accessibilityDomTagName({
source: "nativescript",
type: "Label",
}),
).toBe("simdeck-label");
expect(
accessibilityDomTagName({
source: "react-native",
type: "RangeAndFilterBar",
}),
).toBe("simdeck-range-and-filter-bar");
});
});
Loading
Loading