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.
This commit is contained in:
pj committed 2026-08-22 21:12:05 +05:30
1 parent 4619eb8bfb
commit 52625d95a7
2 files changed
+81 -14

No files matched your search

+75 -2
View File
@@ -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<typeof parseSelector> }[] = [
{ 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");
+6 -12
View File
@@ -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 };