From 52625d95a790138892d4f2930b6bd3638629d7d5 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:12:05 +0530 Subject: [PATCH] fix(replay-ui): the selector key list comes from the cross-runtime contract The hand-copied list carried textPrefix and classPrefix, which are not keys any runtime emits, and omitted testTag, data-testid, identifier and accessibilityIdentifier. testTag is the primary Android identification route, so the most common action rendered as raw text. Importing pkg/spec/test/fixtures/selector-keys.json leaves no second copy to drift. --- replay-ui/src/__tests__/action-format.test.ts | 77 ++++++++++++++++++- replay-ui/src/lib/action-format.ts | 18 ++--- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/replay-ui/src/__tests__/action-format.test.ts b/replay-ui/src/__tests__/action-format.test.ts index 4761a6d..612dcc2 100644 --- a/replay-ui/src/__tests__/action-format.test.ts +++ b/replay-ui/src/__tests__/action-format.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "bun:test"; +import { readFileSync } from "node:fs"; import { + SELECTOR_KEYS, formatActionRow, formatElapsed, parseSelector, @@ -7,6 +9,34 @@ import { } from "../lib/action-format"; import { summary } from "./fixtures"; +const contractPath = new URL( + "../../../pkg/spec/test/fixtures/selector-keys.json", + import.meta.url, +); +const contract: { keys: string[] } = JSON.parse( + readFileSync(contractPath, "utf8"), +); + +// The runtimes reject a selector key they do not know, so the key list the UI +// reads rows against has to be the same list. A key the UI omits renders a real +// action as raw text; a key only the UI carries formats a selector no runtime +// could ever emit. internal/hierarchy/selector_keys_test.go and +// pkg/spec/test/selector-keys.test.ts assert the SAME file. +describe("selector keys", () => { + it("is the cross-runtime list", () => { + expect([...SELECTOR_KEYS]).toEqual(contract.keys); + }); + + it("parses every key the runtimes accept", () => { + for (const key of contract.keys) { + expect(parseSelector(`${key}:value`)).toEqual({ + kind: key, + value: "value", + }); + } + }); +}); + // Bug class: selector parsing mislabels every action row. Splitting on the // wrong colon, treating a value-with-colon as the kind, or dropping the prefix // ellipsis would render the wrong target tag for every step. @@ -14,13 +44,31 @@ describe("parseSelector", () => { const cases: { input: string; out: ReturnType }[] = [ { input: "id:login", out: { kind: "id", value: "login" } }, { input: "text:Sign In", out: { kind: "text", value: "Sign In" } }, - { input: "textPrefix:Hello", out: { kind: "textPrefix", value: "Hello" } }, + { + input: "testTag:AccountCard", + out: { kind: "testTag", value: "AccountCard" }, + }, + { + input: "data-testid:ledger-row", + out: { kind: "data-testid", value: "ledger-row" }, + }, + { + input: "accessibilityIdentifier:pay_now", + out: { kind: "accessibilityIdentifier", value: "pay_now" }, + }, + { + input: "identifier:pay_now", + out: { kind: "identifier", value: "pay_now" }, + }, + { input: "descPrefix:Hello", out: { kind: "descPrefix", value: "Hello" } }, { input: "idPrefix:customer_row_", out: { kind: "idPrefix", value: "customer_row_" }, }, { input: "id:com.app:id/btn", out: { kind: "id", value: "com.app:id/btn" } }, { input: "bogus:x", out: null }, + { input: "textPrefix:Hello", out: null }, + { input: "classPrefix:android.widget", out: null }, { input: ":leading", out: null }, { input: "no-colon", out: null }, ]; @@ -33,9 +81,10 @@ describe("parseSelector", () => { describe("tagFromSelector", () => { it("appends ellipsis only for prefix selectors", () => { - expect(tagFromSelector("textPrefix:Hel")).toBe("Hel..."); + expect(tagFromSelector("descPrefix:Hel")).toBe("Hel..."); expect(tagFromSelector("idPrefix:customer_row_")).toBe("customer_row_..."); expect(tagFromSelector("text:Hello")).toBe("Hello"); + expect(tagFromSelector("testTag:AccountCard")).toBe("AccountCard"); expect(tagFromSelector("plain")).toBe("plain"); }); }); @@ -63,6 +112,30 @@ describe("formatActionRow", () => { ).toEqual({ verb: "Click", target: "(10, 20)", targetIsTag: false }); }); + // internal/verifier/worker.go labels android and ios taps with testTag, + // identifier and accessibilityIdentifier; pkg/spec/src/web-runtime.ts labels + // web taps with data-testid. testTag is the commonest label of the four. + it("formats the selector keys the verifier and the web runtime actually emit", () => { + expect( + formatActionRow( + summary({ action_kind: "Tap", action_label: "testTag:AccountCard" }), + ), + ).toEqual({ verb: "Click", target: "AccountCard", targetIsTag: true }); + expect( + formatActionRow( + summary({ action_kind: "Tap", action_label: "data-testid:ledger-row" }), + ), + ).toEqual({ verb: "Click", target: "ledger-row", targetIsTag: true }); + expect( + formatActionRow( + summary({ + action_kind: "Tap", + action_label: "accessibilityIdentifier:pay_now", + }), + ), + ).toEqual({ verb: "Click", target: "pay_now", targetIsTag: true }); + }); + it("maps known verbs and falls back to the raw kind", () => { expect(formatActionRow(summary({ action_kind: "InputText", action_label: "hi" })).verb).toBe("Type"); expect(formatActionRow(summary({ action_kind: "Swipe" })).verb).toBe("Swipe"); diff --git a/replay-ui/src/lib/action-format.ts b/replay-ui/src/lib/action-format.ts index f791312..7305e3d 100644 --- a/replay-ui/src/lib/action-format.ts +++ b/replay-ui/src/lib/action-format.ts @@ -1,3 +1,4 @@ +import selectorKeysContract from "../../../pkg/spec/test/fixtures/selector-keys.json"; import type { StepSummary } from "../types"; export interface FormattedRow { @@ -6,17 +7,10 @@ export interface FormattedRow { targetIsTag: boolean; } -const SELECTOR_PREFIXES = [ - "id", - "idPrefix", - "text", - "textPrefix", - "desc", - "descPrefix", - "class", - "classPrefix", - "package", -]; +// Imported from the cross-runtime contract rather than restated, because a +// copy here goes stale silently: an unlisted key renders a real action as raw +// text instead of a formatted row, and nothing fails to tell anyone. +export const SELECTOR_KEYS: readonly string[] = selectorKeysContract.keys; export function parseSelector( selector: string, @@ -27,7 +21,7 @@ export function parseSelector( } const kind = selector.slice(0, colonIndex); const value = selector.slice(colonIndex + 1); - if (!SELECTOR_PREFIXES.includes(kind)) { + if (!SELECTOR_KEYS.includes(kind)) { return null; } return { kind, value };