diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index ab126f1d8..cbf9efd23 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -177,7 +177,8 @@ export function AddSmartColumnDialog({ /> Dot and bracket notation, e.g. $.order.total or{" "} - $.items[0].sku. + $.items[0].sku. Use .length for an array, string, or + key count.
diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.test.ts b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts index f58934bdf..f6e48238a 100644 --- a/apps/webapp/app/components/runs/v3/smartColumnData.test.ts +++ b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts @@ -93,6 +93,19 @@ describe("getAtPath", () => { expect(getAtPath(obj, "$.a..b")).toBeUndefined(); expect(getAtPath(obj, "$.a[b]")).toBeUndefined(); }); + + it("computes a dot-accessed .length for arrays, strings, and objects", () => { + const data = { tags: ["a", "b", "c"], name: "hello", info: { x: 1, y: 2 }, count: 5 }; + expect(getAtPath(data, "$.tags.length")).toBe(3); + expect(getAtPath(data, "$.name.length")).toBe(5); + expect(getAtPath(data, "$.info.length")).toBe(2); + expect(getAtPath(data, "$.count.length")).toBeUndefined(); + }); + + it("treats a bracket-quoted ['length'] as a literal key, not the computed length", () => { + expect(getAtPath({ length: 42 }, "$['length']")).toBe(42); + expect(getAtPath({ length: 42 }, "$.length")).toBe(1); + }); }); describe("extractSmartValue", () => { diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.ts b/apps/webapp/app/components/runs/v3/smartColumnData.ts index 28e3dde5f..1d9b47a31 100644 --- a/apps/webapp/app/components/runs/v3/smartColumnData.ts +++ b/apps/webapp/app/components/runs/v3/smartColumnData.ts @@ -57,11 +57,20 @@ export function extractSmartValue(parsed: ParsedSource, path: string): SmartCell const PATH_TOKEN_RE = /\.([^.[\]]+)|\[(\d+)\]|\['([^']*)'\]|\["([^"]*)"\]/g; +type PathToken = + | { kind: "dot"; key: string } + | { kind: "key"; key: string } + | { kind: "index"; index: number }; + /** * Read a value out of a parsed object with dot/bracket notation. Accepts a * leading `$`, dotted keys, and numeric or quoted bracket indices, e.g. * `$.failed`, `suites[0].name`, `$['a.b'].c`. Returns undefined when any * segment is missing. + * + * A dot-accessed `.length` is computed: array/string length, or an object's + * key count. To read a real property literally named `length`, use a bracket + * key (`['length']`). */ export function getAtPath(root: unknown, path: string): unknown { let normalized = path.trim(); @@ -71,7 +80,7 @@ export function getAtPath(root: unknown, path: string): unknown { normalized = `.${normalized}`; } - const tokens: (string | number)[] = []; + const tokens: PathToken[] = []; let lastIndex = 0; PATH_TOKEN_RE.lastIndex = 0; let match: RegExpExecArray | null; @@ -79,18 +88,31 @@ export function getAtPath(root: unknown, path: string): unknown { if (match.index !== lastIndex) return undefined; lastIndex = PATH_TOKEN_RE.lastIndex; - if (match[1] !== undefined) tokens.push(match[1]); - else if (match[2] !== undefined) tokens.push(Number(match[2])); - else if (match[3] !== undefined) tokens.push(match[3]); - else if (match[4] !== undefined) tokens.push(match[4]); + if (match[1] !== undefined) tokens.push({ kind: "dot", key: match[1] }); + else if (match[2] !== undefined) tokens.push({ kind: "index", index: Number(match[2]) }); + else if (match[3] !== undefined) tokens.push({ kind: "key", key: match[3] }); + else if (match[4] !== undefined) tokens.push({ kind: "key", key: match[4] }); } if (lastIndex !== normalized.length) return undefined; let current: unknown = root; for (const token of tokens) { if (current === null || current === undefined) return undefined; + + if (token.kind === "dot" && token.key === "length") { + if (Array.isArray(current) || typeof current === "string") { + current = current.length; + } else if (typeof current === "object") { + current = Object.keys(current).length; + } else { + return undefined; + } + continue; + } + if (typeof current !== "object") return undefined; - current = (current as Record)[token]; + const key = token.kind === "index" ? token.index : token.key; + current = (current as Record)[key]; } return current; }