From e432af782896ebe28f49b45cbc34db906b32c17c Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 17 Aug 2026 23:45:33 +0530 Subject: [PATCH] fix(replay-ui): read data-* attributes by their markup names The web runtime now publishes raw markup attribute names, so attrs["step"] read nothing where the markup writes data-step. Three properties went vacuous and exactlyOneStepIsSelected reported false against a UI that was fine. The test also fails if a dataOf key gains no matching attribute, or if an attribute it derives is rendered nowhere. --- replay-ui/sanderling/data-attributes.ts | 7 +++ replay-ui/sanderling/spec.ts | 3 +- .../src/__tests__/data-attributes.test.ts | 60 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 replay-ui/sanderling/data-attributes.ts create mode 100644 replay-ui/src/__tests__/data-attributes.test.ts diff --git a/replay-ui/sanderling/data-attributes.ts b/replay-ui/sanderling/data-attributes.ts new file mode 100644 index 0000000..fcdaac2 --- /dev/null +++ b/replay-ui/sanderling/data-attributes.ts @@ -0,0 +1,7 @@ +// attrs is keyed by the names the markup writes (rawAttributes in +// pkg/spec/src/web-runtime.ts), and the goja host the properties run in exposes +// attrs and nothing else (internal/verifier/marshal.go): there is no dataset +// field to read a camelCased key off, so the spec converts the key itself. +export function attributeOf(key: string): string { + return `data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; +} diff --git a/replay-ui/sanderling/spec.ts b/replay-ui/sanderling/spec.ts index 75d19d6..3e9b44a 100644 --- a/replay-ui/sanderling/spec.ts +++ b/replay-ui/sanderling/spec.ts @@ -12,6 +12,7 @@ import { type AccessibilityElement, type Key, PressKey, Tap, actions, always, extract, from, next, weighted } from "@sanderling/spec"; import { defaultActions, noUncaughtExceptions } from "@sanderling/spec/defaults"; +import { attributeOf } from "./data-attributes"; function numberOf(value: string | undefined): number | null { if (value === undefined || value === "") return null; @@ -21,7 +22,7 @@ function numberOf(value: string | undefined): number | null { function dataOf(element: AccessibilityElement | undefined, key: string): string | undefined { const attrs = (element as unknown as { attrs?: Record } | undefined)?.attrs; - return attrs ? attrs[key] : undefined; + return attrs ? attrs[attributeOf(key)] : undefined; } // The toolbar's own claim about which step is on screen, and how many there are. diff --git a/replay-ui/src/__tests__/data-attributes.test.ts b/replay-ui/src/__tests__/data-attributes.test.ts new file mode 100644 index 0000000..0f7fd9c --- /dev/null +++ b/replay-ui/src/__tests__/data-attributes.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { attributeOf } from "../../sanderling/data-attributes"; + +// Bug class: the web runtime keys attrs by the raw markup name, so a spec +// reading attrs["stepCount"] gets undefined and every property over it passes +// vacuously against a UI that is broken. +describe("attributeOf", () => { + it("names the attribute the markup writes for each logical key", () => { + expect(attributeOf("step")).toBe("data-step"); + expect(attributeOf("stepCount")).toBe("data-step-count"); + expect(attributeOf("active")).toBe("data-active"); + expect(attributeOf("violations")).toBe("data-violations"); + expect(attributeOf("tabId")).toBe("data-tab-id"); + expect(attributeOf("violationCount")).toBe("data-violation-count"); + }); +}); + +const here = new URL(".", import.meta.url).pathname; +const specPath = join(here, "../../sanderling/spec.ts"); +const sourceRoot = join(here, ".."); + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return entry.name === "__tests__" ? [] : sourceFiles(path); + return entry.name.endsWith(".tsx") || entry.name.endsWith(".ts") ? [path] : []; + }); +} + +function keysReadByTheSpec(): string[] { + const spec = readFileSync(specPath, "utf8"); + const keys = new Set(); + for (const match of spec.matchAll(/dataOf\([^,]+,\s*"([^"]+)"\)/g)) keys.add(match[1]); + return [...keys]; +} + +describe("the keys the spec reads", () => { + const keys = keysReadByTheSpec(); + + it("covers every dataOf call site", () => { + expect(keys.sort()).toEqual([ + "active", + "step", + "stepCount", + "tabId", + "violationCount", + "violations", + ]); + }); + + it("names attributes the UI renders", () => { + const markup = sourceFiles(sourceRoot) + .map((path) => readFileSync(path, "utf8")) + .join("\n"); + const missing = keys.map(attributeOf).filter((attribute) => !markup.includes(`${attribute}=`)); + expect(missing).toEqual([]); + }); +});