From 7f15b7fb2c296e124a804f49c1525dda8edcc18c Mon Sep 17 00:00:00 2001 From: Kaosiso Ezealigo Date: Wed, 3 Jun 2026 11:46:05 +0200 Subject: [PATCH 1/4] Add unit tests for utility functions and configuration for Vitest - Created unit tests for data transformation utilities including error extraction, response status preservation, and metadata stripping. - Added tests for formatting utilities covering number, currency, latency, and percentage formatting. - Implemented tests for path utilities to validate object navigation and manipulation. - Developed tests for slug generation and validation functions. - Added tests for template variable validation and extraction. - Included tests for various validators including UUID and HTTP URL validation. - Configured Vitest for running tests with coverage reporting and JUnit output. --- web/packages/agenta-annotation/package.json | 11 +- .../agenta-annotation/test-results/junit.xml | 163 +++++ .../tests/__mocks__/agenta-ui.ts | 11 + .../unit/annotation-form-helpers.test.ts | 376 ++++++++++ .../tests/unit/testset-sync.test.ts | 659 ++++++++++++++++++ .../agenta-annotation/vitest.config.ts | 28 + web/packages/agenta-shared/package.json | 11 +- .../agenta-shared/test-results/junit.xml | 389 +++++++++++ .../tests/unit/data-transforms.test.ts | 165 +++++ .../tests/unit/formatters.test.ts | 222 ++++++ .../tests/unit/path-utils.test.ts | 166 +++++ .../agenta-shared/tests/unit/slug.test.ts | 234 +++++++ .../tests/unit/template-variable.test.ts | 147 ++++ .../tests/unit/validators-and-ids.test.ts | 138 ++++ web/packages/agenta-shared/vitest.config.ts | 19 + web/pnpm-lock.yaml | 12 + 16 files changed, 2747 insertions(+), 4 deletions(-) create mode 100644 web/packages/agenta-annotation/test-results/junit.xml create mode 100644 web/packages/agenta-annotation/tests/__mocks__/agenta-ui.ts create mode 100644 web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts create mode 100644 web/packages/agenta-annotation/tests/unit/testset-sync.test.ts create mode 100644 web/packages/agenta-annotation/vitest.config.ts create mode 100644 web/packages/agenta-shared/test-results/junit.xml create mode 100644 web/packages/agenta-shared/tests/unit/data-transforms.test.ts create mode 100644 web/packages/agenta-shared/tests/unit/formatters.test.ts create mode 100644 web/packages/agenta-shared/tests/unit/path-utils.test.ts create mode 100644 web/packages/agenta-shared/tests/unit/slug.test.ts create mode 100644 web/packages/agenta-shared/tests/unit/template-variable.test.ts create mode 100644 web/packages/agenta-shared/tests/unit/validators-and-ids.test.ts create mode 100644 web/packages/agenta-shared/vitest.config.ts diff --git a/web/packages/agenta-annotation/package.json b/web/packages/agenta-annotation/package.json index 788f6308af..0874be43f7 100644 --- a/web/packages/agenta-annotation/package.json +++ b/web/packages/agenta-annotation/package.json @@ -8,7 +8,12 @@ "scripts": { "build": "tsc --noEmit", "types:check": "tsc --noEmit", - "lint": "eslint --config ../eslint.config.mjs src/" + "lint": "eslint --config ../eslint.config.mjs src/", + "test": "pnpm run test:unit", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "pnpm run types:check && pnpm run lint" }, "exports": { ".": "./src/index.ts", @@ -26,6 +31,8 @@ }, "devDependencies": { "@types/node": "^20.8.10", - "typescript": "5.8.3" + "@vitest/coverage-v8": "^4.1.4", + "typescript": "5.8.3", + "vitest": "^4.1.4" } } diff --git a/web/packages/agenta-annotation/test-results/junit.xml b/web/packages/agenta-annotation/test-results/junit.xml new file mode 100644 index 0000000000..85cdaef2d1 --- /dev/null +++ b/web/packages/agenta-annotation/test-results/junit.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/packages/agenta-annotation/tests/__mocks__/agenta-ui.ts b/web/packages/agenta-annotation/tests/__mocks__/agenta-ui.ts new file mode 100644 index 0000000000..5e8e4ebab4 --- /dev/null +++ b/web/packages/agenta-annotation/tests/__mocks__/agenta-ui.ts @@ -0,0 +1,11 @@ +/** + * Lightweight stub for @agenta/ui used in Vitest node-env tests. + * The real @agenta/ui pulls in antd which is enormous and causes the Vitest + * transformer to time out. Annotation tests exercise pure functions only. + */ +export const cn = (...args: unknown[]) => args.filter(Boolean).join(" ") +export const textColors = {} +export const bgColors = {} +export const EnhancedModal = () => null +export const ModalContent = () => null +export const ModalFooter = () => null diff --git a/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts b/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts new file mode 100644 index 0000000000..d0a246ce6c --- /dev/null +++ b/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts @@ -0,0 +1,376 @@ +/** + * Unit tests for pure helper functions exported from annotationFormController.ts: + * - isEmptyValue + * - getOutputsSchema + * - getMetricFieldsFromEvaluator + * - getMetricsFromAnnotation + * + * The module has many heavy imports (Jotai atoms, entity API calls, session + * controller). We mock the external packages so no network or Jotai store + * is touched during tests. + */ + +import {beforeEach, describe, expect, it, vi} from "vitest" + +// --------------------------------------------------------------------------- +// Module-level mocks — vi.mock is hoisted before imports by Vitest +// --------------------------------------------------------------------------- + +const mockResolveOutputSchema = vi.fn() + +vi.mock("@agenta/entities/workflow", () => ({ + resolveOutputSchema: (data: unknown) => mockResolveOutputSchema(data), + workflowQueryAtomFamily: () => ({isPending: false, data: null}), + workflowLatestRevisionQueryAtomFamily: () => ({isPending: false, data: null}), +})) + +vi.mock("@agenta/entities/annotation", () => ({ + createAnnotation: vi.fn(), + updateAnnotation: vi.fn(), + invalidateAnnotationCacheByLink: vi.fn(), +})) + +vi.mock("@agenta/entities/evaluationRun", () => ({ + evaluationRunMolecule: {selectors: {annotationSteps: vi.fn(), scenarioSteps: vi.fn()}}, + queryEvaluationResults: vi.fn(), +})) + +vi.mock("@agenta/entities/simpleQueue", () => ({ + invalidateScenarioProgressCache: vi.fn(), + invalidateSimpleQueueCache: vi.fn(), + invalidateSimpleQueuesListCache: vi.fn(), + simpleQueuePaginatedStore: {refreshAtom: {}}, +})) + +vi.mock("@agenta/entities/trace", () => ({ + fetchPreviewTrace: vi.fn(), +})) + +vi.mock("@agenta/shared/api", () => ({ + axios: {patch: vi.fn(), post: vi.fn()}, + getAgentaApiUrl: () => "http://localhost", + queryClient: {invalidateQueries: vi.fn()}, +})) + +vi.mock("@agenta/shared/state", () => ({ + projectIdAtom: {}, +})) + +vi.mock("../../src/state/controllers/annotationSessionController", () => ({ + annotationSessionController: { + selectors: { + evaluatorStepRefs: () => ({}), + scenarioAnnotations: () => ({}), + scenarioStatuses: () => ({}), + activeRunId: () => ({}), + focusAutoNext: () => ({}), + }, + set: {markCompleted: vi.fn(), navigateNext: vi.fn()}, + cache: {invalidateScenarioAnnotations: vi.fn()}, + }, +})) + +// Import the functions AFTER all vi.mock() declarations +import { + getMetricFieldsFromEvaluator, + getMetricsFromAnnotation, + getOutputsSchema, + isEmptyValue, +} from "../../src/state/controllers/annotationFormController" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeWorkflow(schemaProperties: Record = {}) { + // resolveOutputSchema is mocked to return its input, + // so we set data to the schema shape directly. + return { + data: {properties: schemaProperties}, + slug: "test-evaluator", + id: "wf-1", + } as any +} + +function makeAnnotation( + outputs: Record, + references?: {evaluator?: {slug?: string}}, +) { + return { + trace_id: "trace-1", + span_id: "span-1", + data: {outputs}, + references, + meta: {}, + } as any +} + +beforeEach(() => { + // Default: resolveOutputSchema returns the data as-is (pass-through) + mockResolveOutputSchema.mockImplementation((data: unknown) => data) +}) + +// --------------------------------------------------------------------------- +// isEmptyValue +// --------------------------------------------------------------------------- + +describe("isEmptyValue", () => { + it.each([ + [null, true], + [undefined, true], + ["", true], + [[], true], + ])("returns true for %s", (value, expected) => { + expect(isEmptyValue(value)).toBe(expected) + }) + + it.each([ + [0, false], + [false, false], + ["0", false], + [[null], false], + [{}, false], + [" ", false], + ])("returns false for %s", (value, expected) => { + expect(isEmptyValue(value)).toBe(expected) + }) +}) + +// --------------------------------------------------------------------------- +// getOutputsSchema +// --------------------------------------------------------------------------- + +describe("getOutputsSchema", () => { + it("returns the schema from resolveOutputSchema", () => { + const schema = {properties: {score: {type: "number"}}} + const workflow = makeWorkflow(schema.properties) + const result = getOutputsSchema(workflow) + expect(result).toMatchObject({properties: {score: {type: "number"}}}) + }) + + it("returns empty object when resolveOutputSchema returns null", () => { + mockResolveOutputSchema.mockReturnValueOnce(null) + const result = getOutputsSchema(makeWorkflow()) + expect(result).toEqual({}) + }) +}) + +// --------------------------------------------------------------------------- +// getMetricFieldsFromEvaluator — scalar types +// --------------------------------------------------------------------------- + +describe("getMetricFieldsFromEvaluator — scalar types", () => { + it("produces a number field with null default", () => { + const wf = makeWorkflow({score: {type: "number", minimum: 0, maximum: 10}}) + const fields = getMetricFieldsFromEvaluator(wf) + expect(fields.score).toMatchObject({value: null, type: "number", minimum: 0, maximum: 10}) + }) + + it("produces an integer field with null default", () => { + const wf = makeWorkflow({count: {type: "integer"}}) + expect(getMetricFieldsFromEvaluator(wf).count).toMatchObject({value: null, type: "integer"}) + }) + + it("produces a boolean field with null default", () => { + const wf = makeWorkflow({approved: {type: "boolean"}}) + expect(getMetricFieldsFromEvaluator(wf).approved).toMatchObject({ + value: null, + type: "boolean", + }) + }) + + it("produces a string field with empty-string default", () => { + const wf = makeWorkflow({notes: {type: "string"}}) + expect(getMetricFieldsFromEvaluator(wf).notes).toMatchObject({value: "", type: "string"}) + }) +}) + +describe("getMetricFieldsFromEvaluator — array type", () => { + it("produces an array field with item schema", () => { + const wf = makeWorkflow({ + labels: { + type: "array", + items: {type: "string", enum: ["good", "bad"]}, + }, + }) + const fields = getMetricFieldsFromEvaluator(wf) + expect(fields.labels).toMatchObject({ + value: [], + type: "array", + items: {type: "string", enum: ["good", "bad"]}, + }) + }) + + it("defaults item type to string when items is missing", () => { + const wf = makeWorkflow({tags: {type: "array"}}) + expect(getMetricFieldsFromEvaluator(wf).tags.items).toMatchObject({ + type: "string", + enum: [], + }) + }) +}) + +describe("getMetricFieldsFromEvaluator — anyOf schema", () => { + it("unwraps the first anyOf entry to get the real type", () => { + const wf = makeWorkflow({ + score: {anyOf: [{type: "number", minimum: 0}, {type: "null"}]}, + }) + expect(getMetricFieldsFromEvaluator(wf).score).toMatchObject({value: null, type: "number"}) + }) +}) + +describe("getMetricFieldsFromEvaluator — array-of-types", () => { + it("filters 'null' from the type array and uses the remaining types", () => { + const wf = makeWorkflow({status: {type: ["string", "null"]}}) + const field = getMetricFieldsFromEvaluator(wf).status + expect(field.type).toEqual(["string"]) + expect(field.value).toBe("") + }) + + it("skips the property when only 'null' type remains after filtering", () => { + const wf = makeWorkflow({x: {type: ["null"]}}) + expect(getMetricFieldsFromEvaluator(wf)).not.toHaveProperty("x") + }) + + it("includes non-null enum values and strips null/empty entries", () => { + const wf = makeWorkflow({ + choice: {type: ["string", "null"], enum: ["a", null, "", "b"]}, + }) + const field = getMetricFieldsFromEvaluator(wf).choice + expect(field.enum).toEqual(["a", "b"]) + }) +}) + +describe("getMetricFieldsFromEvaluator — edge cases", () => { + it("returns empty object for an empty schema", () => { + mockResolveOutputSchema.mockReturnValueOnce(null) + expect(getMetricFieldsFromEvaluator(makeWorkflow())).toEqual({}) + }) + + it("skips unsupported types (e.g. 'object')", () => { + const wf = makeWorkflow({meta: {type: "object"}}) + expect(getMetricFieldsFromEvaluator(wf)).not.toHaveProperty("meta") + }) + + it("skips properties with no type field", () => { + const wf = makeWorkflow({weird: {description: "no type here"}}) + expect(getMetricFieldsFromEvaluator(wf)).not.toHaveProperty("weird") + }) +}) + +// --------------------------------------------------------------------------- +// getMetricsFromAnnotation — flat outputs +// --------------------------------------------------------------------------- + +describe("getMetricsFromAnnotation — flat outputs matching schema", () => { + it("fills a number field from flat outputs", () => { + const wf = makeWorkflow({score: {type: "number"}}) + const ann = makeAnnotation({score: 8.5}) + const fields = getMetricsFromAnnotation(ann, wf) + expect(fields.score).toMatchObject({value: 8.5, type: "number"}) + }) + + it("fills a string field from flat outputs", () => { + // "notes" is a reserved flattening key — use a plain field name + const wf = makeWorkflow({label: {type: "string"}}) + const ann = makeAnnotation({label: "looks good"}) + expect(getMetricsFromAnnotation(ann, wf).label).toMatchObject({ + value: "looks good", + type: "string", + }) + }) + + it("uses schema default when key is absent in outputs", () => { + const wf = makeWorkflow({score: {type: "number"}}) + const ann = makeAnnotation({}) + expect(getMetricsFromAnnotation(ann, wf).score).toMatchObject({value: null, type: "number"}) + }) + + it("uses '' as default for a missing string field", () => { + const wf = makeWorkflow({label: {type: "string"}}) + const ann = makeAnnotation({}) + expect(getMetricsFromAnnotation(ann, wf).label.value).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// getMetricsFromAnnotation — nested output structures +// --------------------------------------------------------------------------- + +describe("getMetricsFromAnnotation — nested outputs", () => { + it("flattens metrics nested under 'metrics' key", () => { + const wf = makeWorkflow({score: {type: "number"}}) + const ann = makeAnnotation({metrics: {score: 9}}) + expect(getMetricsFromAnnotation(ann, wf).score.value).toBe(9) + }) + + it("flattens fields nested under 'notes' key", () => { + const wf = makeWorkflow({comment: {type: "string"}}) + const ann = makeAnnotation({notes: {comment: "great"}}) + expect(getMetricsFromAnnotation(ann, wf).comment.value).toBe("great") + }) + + it("flattens fields nested under 'extra' key", () => { + const wf = makeWorkflow({custom: {type: "string"}}) + const ann = makeAnnotation({extra: {custom: "value"}}) + expect(getMetricsFromAnnotation(ann, wf).custom.value).toBe("value") + }) + + it("flat keys outside of metrics/notes/extra are preserved directly", () => { + const wf = makeWorkflow({direct: {type: "number"}}) + const ann = makeAnnotation({direct: 42}) + expect(getMetricsFromAnnotation(ann, wf).direct.value).toBe(42) + }) +}) + +// --------------------------------------------------------------------------- +// getMetricsFromAnnotation — schema-free (infer from outputs) +// --------------------------------------------------------------------------- + +describe("getMetricsFromAnnotation — schema-free inference", () => { + beforeEach(() => { + // Empty schema → falls back to inferFieldsFromOutputs + mockResolveOutputSchema.mockReturnValue(null) + }) + + it("infers a number field from a numeric output value", () => { + const wf = makeWorkflow() + const ann = makeAnnotation({score: 7}) + const fields = getMetricsFromAnnotation(ann, wf) + expect(fields.score.type).toBe("integer") + expect(fields.score.value).toBe(7) + }) + + it("infers a boolean field from a boolean output value", () => { + const wf = makeWorkflow() + const ann = makeAnnotation({approved: true}) + expect(getMetricsFromAnnotation(ann, wf).approved).toMatchObject({ + value: true, + type: "boolean", + }) + }) + + it("infers a string field from a string output value", () => { + // "notes" is a reserved key — use a plain field name + const wf = makeWorkflow() + const ann = makeAnnotation({comment: "hello"}) + expect(getMetricsFromAnnotation(ann, wf).comment).toMatchObject({ + value: "hello", + type: "string", + }) + }) + + it("serialises an object output to a JSON string field", () => { + const wf = makeWorkflow() + const ann = makeAnnotation({meta: {key: "val"}}) + const field = getMetricsFromAnnotation(ann, wf).meta + expect(field.type).toBe("string") + expect(field.value).toBe(JSON.stringify({key: "val"})) + }) + + it("returns empty object when annotation outputs are empty", () => { + const wf = makeWorkflow() + const ann = makeAnnotation({}) + expect(getMetricsFromAnnotation(ann, wf)).toEqual({}) + }) +}) diff --git a/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts b/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts new file mode 100644 index 0000000000..598c60708d --- /dev/null +++ b/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts @@ -0,0 +1,659 @@ +/** + * Unit tests for pure functions in src/state/testsetSync.ts. + * + * All functions under test are pure data transformations with no side effects. + * The entity imports in testsetSync.ts are type-only, so no mocking is needed. + */ + +import {describe, expect, it} from "vitest" + +import type {Annotation} from "../../src/state/testsetSync" +import { + buildTestcaseExportRows, + buildTestsetSyncOperations, + buildTestsetSyncPreview, + buildTraceTestsetRows, + getQueueAnnotationTag, + getTestsetSyncEvaluatorColumnKey, + mergeTestcaseAnnotationTags, + remapTargetRowsToBaseRevision, + selectQueueScopedAnnotation, + TESTCASE_QUEUE_KIND_TAG, +} from "../../src/state/testsetSync" + +// --------------------------------------------------------------------------- +// Minimal fixture builders +// --------------------------------------------------------------------------- + +function makeAnnotation( + overrides: { + evaluatorSlug?: string + evaluatorId?: string + tags?: string[] + outputs?: Record + traceId?: string + spanId?: string + } = {}, +): Annotation { + return { + trace_id: overrides.traceId ?? "trace-1", + span_id: overrides.spanId ?? "span-1", + meta: {tags: overrides.tags ?? []}, + references: { + evaluator: { + id: overrides.evaluatorId, + slug: overrides.evaluatorSlug, + }, + }, + data: {outputs: overrides.outputs ?? {}}, + } as unknown as Annotation +} + +function queueTag(queueId: string) { + return `agenta:queue:${queueId}` +} + +// --------------------------------------------------------------------------- +// getQueueAnnotationTag +// --------------------------------------------------------------------------- + +describe("getQueueAnnotationTag", () => { + it("formats queue ID into tag", () => { + expect(getQueueAnnotationTag("q-abc")).toBe("agenta:queue:q-abc") + }) + + it("handles arbitrary queue IDs", () => { + expect(getQueueAnnotationTag("123-456-789")).toBe("agenta:queue:123-456-789") + }) +}) + +// --------------------------------------------------------------------------- +// mergeTestcaseAnnotationTags +// --------------------------------------------------------------------------- + +describe("mergeTestcaseAnnotationTags", () => { + it("always includes the queue tag and kind tag", () => { + const tags = mergeTestcaseAnnotationTags({queueId: "q-1"}) + expect(tags).toContain(queueTag("q-1")) + expect(tags).toContain(TESTCASE_QUEUE_KIND_TAG) + }) + + it("merges existing tags without duplicates", () => { + const tags = mergeTestcaseAnnotationTags({ + queueId: "q-1", + existingTags: ["score", "notes", queueTag("q-1")], + outputKeys: ["score"], + }) + expect(tags.filter((t) => t === "score")).toHaveLength(1) + expect(tags.filter((t) => t === queueTag("q-1"))).toHaveLength(1) + expect(tags).toContain("notes") + }) + + it("adds output keys as tags", () => { + const tags = mergeTestcaseAnnotationTags({ + queueId: "q-1", + outputKeys: ["relevance", "fluency"], + }) + expect(tags).toContain("relevance") + expect(tags).toContain("fluency") + }) + + it("handles null existingTags gracefully", () => { + const tags = mergeTestcaseAnnotationTags({queueId: "q-1", existingTags: null}) + expect(tags).toContain(queueTag("q-1")) + expect(tags).toContain(TESTCASE_QUEUE_KIND_TAG) + }) + + it("filters out falsy tags from existingTags", () => { + const tags = mergeTestcaseAnnotationTags({ + queueId: "q-1", + existingTags: ["", null as unknown as string, "valid-tag"], + }) + expect(tags).not.toContain("") + expect(tags).not.toContain(null) + expect(tags).toContain("valid-tag") + }) +}) + +// --------------------------------------------------------------------------- +// selectQueueScopedAnnotation +// --------------------------------------------------------------------------- + +describe("selectQueueScopedAnnotation — no match", () => { + it("returns null annotation when list is empty", () => { + const result = selectQueueScopedAnnotation({ + annotations: [], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + expect(result).toEqual({annotation: null, conflictCode: null}) + }) + + it("returns null annotation when no annotation matches the evaluator slug", () => { + const ann = makeAnnotation({evaluatorSlug: "other-evaluator"}) + const result = selectQueueScopedAnnotation({ + annotations: [ann], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + expect(result).toEqual({annotation: null, conflictCode: null}) + }) +}) + +describe("selectQueueScopedAnnotation — queue-scoped matching", () => { + it("returns the annotation when exactly one queue-scoped match exists", () => { + const ann = makeAnnotation({ + evaluatorSlug: "relevance", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + }) + const result = selectQueueScopedAnnotation({ + annotations: [ann], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + expect(result).toEqual({annotation: ann, conflictCode: null}) + }) + + it("returns duplicate_queue_annotations when multiple queue-scoped annotations match", () => { + const ann1 = makeAnnotation({ + evaluatorSlug: "relevance", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + traceId: "trace-1", + }) + const ann2 = makeAnnotation({ + evaluatorSlug: "relevance", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + traceId: "trace-2", + }) + const result = selectQueueScopedAnnotation({ + annotations: [ann1, ann2], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + expect(result).toEqual({annotation: null, conflictCode: "duplicate_queue_annotations"}) + }) + + it("ignores annotations scoped to a different queue", () => { + const ann = makeAnnotation({ + evaluatorSlug: "relevance", + tags: [queueTag("q-OTHER"), TESTCASE_QUEUE_KIND_TAG], + }) + const result = selectQueueScopedAnnotation({ + annotations: [ann], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + // Not a queue-scoped match for q-1, and it has a queue tag → not legacy either + expect(result.annotation).toBeNull() + expect(result.conflictCode).toBeNull() + }) +}) + +describe("selectQueueScopedAnnotation — legacy fallback", () => { + it("falls back to a legacy annotation (no queue tags) when no queue-scoped match", () => { + const ann = makeAnnotation({ + evaluatorSlug: "relevance", + tags: [], // no queue tags → legacy + }) + const result = selectQueueScopedAnnotation({ + annotations: [ann], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + expect(result).toEqual({annotation: ann, conflictCode: null}) + }) + + it("returns duplicate_legacy_annotations when multiple legacy annotations match", () => { + const ann1 = makeAnnotation({evaluatorSlug: "relevance", tags: [], traceId: "trace-1"}) + const ann2 = makeAnnotation({evaluatorSlug: "relevance", tags: [], traceId: "trace-2"}) + const result = selectQueueScopedAnnotation({ + annotations: [ann1, ann2], + queueId: "q-1", + evaluatorSlug: "relevance", + }) + expect(result).toEqual({annotation: null, conflictCode: "duplicate_legacy_annotations"}) + }) +}) + +describe("selectQueueScopedAnnotation — evaluatorWorkflowId matching", () => { + it("matches annotation by evaluator workflow ID", () => { + const ann = makeAnnotation({ + evaluatorId: "wf-abc", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + }) + const result = selectQueueScopedAnnotation({ + annotations: [ann], + queueId: "q-1", + evaluatorSlug: "relevance", + evaluatorWorkflowId: "wf-abc", + }) + expect(result).toEqual({annotation: ann, conflictCode: null}) + }) +}) + +// --------------------------------------------------------------------------- +// getTestsetSyncEvaluatorColumnKey +// --------------------------------------------------------------------------- + +describe("getTestsetSyncEvaluatorColumnKey", () => { + const evaluator = {slug: "relevance", workflowId: "wf-1"} + + it("returns evaluator slug when no annotation supplied", () => { + expect(getTestsetSyncEvaluatorColumnKey({evaluator})).toBe("relevance") + }) + + it("prefers annotation's evaluator slug over evaluator.slug", () => { + const ann = makeAnnotation({evaluatorSlug: "resolved-slug"}) + expect(getTestsetSyncEvaluatorColumnKey({evaluator, annotation: ann})).toBe("resolved-slug") + }) + + it("falls back to evaluator.workflowId when slug is empty", () => { + const noSlugEval = {slug: "", workflowId: "wf-fallback"} + expect(getTestsetSyncEvaluatorColumnKey({evaluator: noSlugEval})).toBe("wf-fallback") + }) + + it("returns empty string when evaluator has no slug or workflowId", () => { + expect(getTestsetSyncEvaluatorColumnKey({evaluator: {slug: "", workflowId: ""}})).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// buildTestsetSyncOperations +// --------------------------------------------------------------------------- + +describe("buildTestsetSyncOperations", () => { + it("maps target rows to replace operations", () => { + const target = { + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 2, + rows: [ + { + scenarioId: "s-1", + testcaseId: "tc-1", + testsetId: "ts-1", + rowId: "r-1", + data: {x: 1}, + }, + { + scenarioId: "s-2", + testcaseId: "tc-2", + testsetId: "ts-1", + rowId: "r-2", + data: {x: 2}, + }, + ], + } + + const ops = buildTestsetSyncOperations(target) + expect(ops).toEqual({ + rows: { + replace: [ + {id: "r-1", data: {x: 1}}, + {id: "r-2", data: {x: 2}}, + ], + }, + }) + }) + + it("produces an empty replace list for a target with no rows", () => { + const ops = buildTestsetSyncOperations({ + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 0, + rows: [], + }) + expect(ops.rows.replace).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// remapTargetRowsToBaseRevision +// --------------------------------------------------------------------------- + +describe("remapTargetRowsToBaseRevision", () => { + it("keeps rows whose rowId exists directly in baseRows", () => { + const target = { + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 1, + rows: [ + {scenarioId: "s-1", testcaseId: "tc-1", testsetId: "ts-1", rowId: "r-1", data: {}}, + ], + } + const {target: result, droppedRowCount} = remapTargetRowsToBaseRevision({ + target, + baseRows: [{id: "r-1"}], + }) + expect(result.rows).toHaveLength(1) + expect(result.rows[0].rowId).toBe("r-1") + expect(droppedRowCount).toBe(0) + }) + + it("remaps a row using testcase_dedup_id when rowId is not in baseRows", () => { + const target = { + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 1, + rows: [ + { + scenarioId: "s-1", + testcaseId: "tc-1", + testsetId: "ts-1", + rowId: "old-id", + data: {testcase_dedup_id: "dedup-abc"}, + }, + ], + } + const {target: result, droppedRowCount} = remapTargetRowsToBaseRevision({ + target, + baseRows: [{id: "new-id", data: {testcase_dedup_id: "dedup-abc"}}], + }) + expect(result.rows[0].rowId).toBe("new-id") + expect(droppedRowCount).toBe(0) + }) + + it("also remaps using legacy __dedup_id__ key", () => { + const target = { + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 1, + rows: [ + { + scenarioId: "s-1", + testcaseId: "tc-1", + testsetId: "ts-1", + rowId: "old-id", + data: {__dedup_id__: "dedup-xyz"}, + }, + ], + } + const {target: result, droppedRowCount} = remapTargetRowsToBaseRevision({ + target, + baseRows: [{id: "mapped-id", data: {__dedup_id__: "dedup-xyz"}}], + }) + expect(result.rows[0].rowId).toBe("mapped-id") + expect(droppedRowCount).toBe(0) + }) + + it("drops rows with no matching rowId and no dedup key", () => { + const target = { + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 1, + rows: [ + {scenarioId: "s-1", testcaseId: "tc-1", testsetId: "ts-1", rowId: "gone", data: {}}, + ], + } + const {target: result, droppedRowCount} = remapTargetRowsToBaseRevision({ + target, + baseRows: [{id: "other-id"}], + }) + expect(result.rows).toHaveLength(0) + expect(droppedRowCount).toBe(1) + }) + + it("updates rowCount to reflect mapped rows only", () => { + const target = { + testsetId: "ts-1", + baseRevisionId: "rev-1", + rowCount: 2, + rows: [ + {scenarioId: "s-1", testcaseId: "tc-1", testsetId: "ts-1", rowId: "r-1", data: {}}, + {scenarioId: "s-2", testcaseId: "tc-2", testsetId: "ts-1", rowId: "gone", data: {}}, + ], + } + const {target: result, droppedRowCount} = remapTargetRowsToBaseRevision({ + target, + baseRows: [{id: "r-1"}], + }) + expect(result.rowCount).toBe(1) + expect(droppedRowCount).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// buildTraceTestsetRows +// --------------------------------------------------------------------------- + +describe("buildTraceTestsetRows", () => { + it("builds a row per scenario with trace inputs and output", () => { + const rows = buildTraceTestsetRows({ + scenarioIds: ["s-1"], + traceInputsByScenario: new Map([["s-1", {question: "What is AI?"}]]), + traceOutputsByScenario: new Map([["s-1", "AI is..."]]), + annotationsByScenario: new Map(), + outputColumnName: "answer", + }) + expect(rows).toHaveLength(1) + expect(rows[0].scenarioId).toBe("s-1") + expect(rows[0].data.question).toBe("What is AI?") + expect(rows[0].data.answer).toBe("AI is...") + }) + + it("expands a nested 'inputs' key into top-level columns", () => { + const rows = buildTraceTestsetRows({ + scenarioIds: ["s-1"], + traceInputsByScenario: new Map([["s-1", {inputs: {a: 1, b: 2}}]]), + traceOutputsByScenario: new Map(), + annotationsByScenario: new Map(), + outputColumnName: "output", + }) + expect(rows[0].data.a).toBe(1) + expect(rows[0].data.b).toBe(2) + expect(rows[0].data).not.toHaveProperty("inputs") + }) + + it("merges annotation outputs into the row", () => { + const rows = buildTraceTestsetRows({ + scenarioIds: ["s-1"], + traceInputsByScenario: new Map([["s-1", {q: "hi"}]]), + traceOutputsByScenario: new Map([["s-1", "hello"]]), + annotationsByScenario: new Map([["s-1", {relevance: {score: 5}}]]), + outputColumnName: "output", + }) + expect(rows[0].data.relevance).toMatchObject({score: 5}) + }) + + it("handles a missing scenario gracefully (uses empty defaults)", () => { + const rows = buildTraceTestsetRows({ + scenarioIds: ["s-missing"], + traceInputsByScenario: new Map(), + traceOutputsByScenario: new Map(), + annotationsByScenario: new Map(), + outputColumnName: "output", + }) + expect(rows).toHaveLength(1) + expect(rows[0].data.output).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// buildTestcaseExportRows +// --------------------------------------------------------------------------- + +describe("buildTestcaseExportRows", () => { + const evaluator = {slug: "quality", workflowId: "wf-q"} + + function makeTestcase(id: string, testsetId: string) { + return {id, testset_id: testsetId, data: {prompt: "hello"}} + } + + it("builds a row when annotation data exists for the testcase", () => { + const ann = makeAnnotation({ + evaluatorSlug: "quality", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + outputs: {score: 8}, + }) + const rows = buildTestcaseExportRows({ + scenarioIds: ["s-1"], + testcasesByScenarioId: new Map([["s-1", makeTestcase("tc-1", "ts-1") as any]]), + annotationsByTestcaseId: new Map([["tc-1", [ann]]]), + evaluators: [evaluator], + queueId: "q-1", + }) + expect(rows).toHaveLength(1) + expect(rows[0].testcaseId).toBe("tc-1") + expect(rows[0].testsetId).toBe("ts-1") + expect((rows[0].data as any).quality).toMatchObject({score: 8}) + }) + + it("skips a scenario with no testcase mapping", () => { + const rows = buildTestcaseExportRows({ + scenarioIds: ["s-missing"], + testcasesByScenarioId: new Map(), + annotationsByTestcaseId: new Map(), + evaluators: [evaluator], + queueId: "q-1", + }) + expect(rows).toHaveLength(0) + }) + + it("skips a testcase with no annotations", () => { + const rows = buildTestcaseExportRows({ + scenarioIds: ["s-1"], + testcasesByScenarioId: new Map([["s-1", makeTestcase("tc-1", "ts-1") as any]]), + annotationsByTestcaseId: new Map([["tc-1", []]]), + evaluators: [evaluator], + queueId: "q-1", + }) + expect(rows).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// buildTestsetSyncPreview +// --------------------------------------------------------------------------- + +describe("buildTestsetSyncPreview", () => { + const evaluator = {slug: "quality", workflowId: "wf-q"} + + function makeTestcase(id: string, testsetId: string) { + return {id, testset_id: testsetId, data: {}} + } + + function makeQueueAnn(traceId = "trace-1") { + return makeAnnotation({ + evaluatorSlug: "quality", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + outputs: {score: 7}, + traceId, + }) + } + + it("returns a missing_testcase conflict when testcase not found", () => { + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-missing"}], + testcasesById: new Map(), + annotationsByTestcaseId: new Map(), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map(), + }) + expect(preview.conflicts).toHaveLength(1) + expect(preview.conflicts[0].code).toBe("missing_testcase") + expect(preview.hasBlockingConflicts).toBe(true) + }) + + it("returns a missing_testset conflict when testcase has no testset_id", () => { + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], + testcasesById: new Map([["tc-1", {id: "tc-1", data: {}} as any]]), + annotationsByTestcaseId: new Map(), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map(), + }) + expect(preview.conflicts[0].code).toBe("missing_testset") + }) + + it("returns a missing_latest_revision conflict when no revision for testset", () => { + const ann = makeQueueAnn() + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + annotationsByTestcaseId: new Map([["tc-1", [ann]]]), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map(), // ts-1 has no revision + }) + expect(preview.conflicts.some((c) => c.code === "missing_latest_revision")).toBe(true) + }) + + it("produces a clean target when everything is resolved", () => { + const ann = makeQueueAnn() + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + annotationsByTestcaseId: new Map([["tc-1", [ann]]]), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), + }) + expect(preview.conflicts).toHaveLength(0) + expect(preview.targets).toHaveLength(1) + expect(preview.targets[0].testsetId).toBe("ts-1") + expect(preview.targets[0].baseRevisionId).toBe("rev-1") + expect(preview.exportableRows).toBe(1) + expect(preview.hasBlockingConflicts).toBe(false) + }) + + it("records duplicate_queue_annotations conflict and skips the row", () => { + const ann1 = makeQueueAnn("trace-1") + const ann2 = makeQueueAnn("trace-2") + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + annotationsByTestcaseId: new Map([["tc-1", [ann1, ann2]]]), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), + }) + expect(preview.conflicts[0].code).toBe("duplicate_queue_annotations") + expect(preview.exportableRows).toBe(0) + expect(preview.hasBlockingConflicts).toBe(true) + }) + + it("groups rows from different scenarios under the same testset target", () => { + const ann1 = makeQueueAnn("trace-1") + const ann2 = makeQueueAnn("trace-2") + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [ + {scenarioId: "s-1", testcaseId: "tc-1"}, + {scenarioId: "s-2", testcaseId: "tc-2"}, + ], + testcasesById: new Map([ + ["tc-1", makeTestcase("tc-1", "ts-1") as any], + ["tc-2", makeTestcase("tc-2", "ts-1") as any], + ]), + annotationsByTestcaseId: new Map([ + ["tc-1", [ann1]], + ["tc-2", [ann2]], + ]), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), + }) + expect(preview.targets).toHaveLength(1) + expect(preview.targets[0].rowCount).toBe(2) + expect(preview.exportableRows).toBe(2) + }) + + it("skips rows with no annotation data and does not add them as conflicts", () => { + const annNoOutputs = makeAnnotation({ + evaluatorSlug: "quality", + tags: [queueTag("q-1"), TESTCASE_QUEUE_KIND_TAG], + outputs: {}, // empty + }) + const preview = buildTestsetSyncPreview({ + queueId: "q-1", + completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + annotationsByTestcaseId: new Map([["tc-1", [annNoOutputs]]]), + evaluators: [evaluator], + latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), + }) + expect(preview.conflicts).toHaveLength(0) + expect(preview.exportableRows).toBe(0) + }) +}) diff --git a/web/packages/agenta-annotation/vitest.config.ts b/web/packages/agenta-annotation/vitest.config.ts new file mode 100644 index 0000000000..92bca1ab9d --- /dev/null +++ b/web/packages/agenta-annotation/vitest.config.ts @@ -0,0 +1,28 @@ +import path from "path" + +import {defineConfig} from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { + // Stub @agenta/ui to avoid pulling in the full antd tree. + // Annotation tests only exercise pure functions — no React rendering. + "@agenta/ui": path.resolve(__dirname, "tests/__mocks__/agenta-ui.ts"), + }, + }, + test: { + include: ["tests/unit/**/*.test.ts"], + environment: "node", + reporters: ["default", "junit"], + outputFile: { + junit: "./test-results/junit.xml", + }, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/index.ts"], + reporter: ["text", "lcov", "json-summary"], + reportsDirectory: "./coverage", + }, + }, +}) diff --git a/web/packages/agenta-shared/package.json b/web/packages/agenta-shared/package.json index 2d49bad1cb..7b13e46b14 100644 --- a/web/packages/agenta-shared/package.json +++ b/web/packages/agenta-shared/package.json @@ -9,7 +9,12 @@ "build": "pnpm run types:check", "types:check": "tsc --noEmit", "lint": "eslint --config ../eslint.config.mjs src/ --max-warnings 0", - "lint:fix": "eslint --config ../eslint.config.mjs src/ --max-warnings 0 --fix" + "lint:fix": "eslint --config ../eslint.config.mjs src/ --max-warnings 0 --fix", + "test": "pnpm run test:unit", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "pnpm run types:check && pnpm run lint" }, "exports": { ".": "./src/index.ts", @@ -31,7 +36,9 @@ "devDependencies": { "@types/node": "^20.8.10", "@types/react": "^19.0.10", - "typescript": "5.8.3" + "@vitest/coverage-v8": "^4.1.4", + "typescript": "5.8.3", + "vitest": "^4.1.4" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", diff --git a/web/packages/agenta-shared/test-results/junit.xml b/web/packages/agenta-shared/test-results/junit.xml new file mode 100644 index 0000000000..ba991a034e --- /dev/null +++ b/web/packages/agenta-shared/test-results/junit.xml @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/packages/agenta-shared/tests/unit/data-transforms.test.ts b/web/packages/agenta-shared/tests/unit/data-transforms.test.ts new file mode 100644 index 0000000000..148b7a78dd --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/data-transforms.test.ts @@ -0,0 +1,165 @@ +import {describe, expect, it} from "vitest" + +import { + extractApiErrorMessage, + preserveResponseStatus, +} from "../../src/utils/extractApiErrorMessage" +import {stripAgentaMetadataDeep, stripEnhancedWrappers} from "../../src/utils/valueExtraction" + +// --------------------------------------------------------------------------- +// extractApiErrorMessage +// --------------------------------------------------------------------------- + +describe("extractApiErrorMessage — Axios-style errors", () => { + it("extracts from response.data.detail string", () => { + const error = {response: {data: {detail: "Not found"}}} + expect(extractApiErrorMessage(error)).toBe("Not found") + }) + + it("extracts from response.data.message string", () => { + const error = {response: {data: {message: "Forbidden"}}} + expect(extractApiErrorMessage(error)).toBe("Forbidden") + }) + + it("extracts from response.data.error string", () => { + const error = {response: {data: {error: "Internal error"}}} + expect(extractApiErrorMessage(error)).toBe("Internal error") + }) + + it("extracts from nested response.data.detail.message", () => { + const error = {response: {data: {detail: {message: "Nested message"}}}} + expect(extractApiErrorMessage(error)).toBe("Nested message") + }) + + it("extracts from an array of detail strings", () => { + const error = {response: {data: {detail: ["error one", "error two"]}}} + const result = extractApiErrorMessage(error) + expect(result).toContain("error one") + }) +}) + +describe("extractApiErrorMessage — Error instances", () => { + it("returns error.message for a plain Error", () => { + expect(extractApiErrorMessage(new Error("Something failed"))).toBe("Something failed") + }) +}) + +describe("extractApiErrorMessage — direct string/object", () => { + it("returns a non-empty string value directly", () => { + expect(extractApiErrorMessage("plain error string")).toBe("plain error string") + }) + + it("falls back to String(error) for unknown shapes", () => { + expect(extractApiErrorMessage(42)).toBe("42") + }) +}) + +// --------------------------------------------------------------------------- +// preserveResponseStatus +// --------------------------------------------------------------------------- + +describe("preserveResponseStatus", () => { + it("wraps an error with a custom message", () => { + const err = preserveResponseStatus(new Error("original"), "custom message") + expect(err.message).toBe("custom message") + }) + + it("preserves the response status from the original error", () => { + const axiosError = {response: {status: 404}, message: "Not found"} + const err = preserveResponseStatus(axiosError, "Not found") + expect(err.response?.status).toBe(404) + }) + + it("preserves the original error message when no override is given", () => { + const err = preserveResponseStatus(new Error("original")) + expect(err.message).toBe("original") + }) +}) + +// --------------------------------------------------------------------------- +// stripAgentaMetadataDeep +// --------------------------------------------------------------------------- + +describe("stripAgentaMetadataDeep", () => { + it("removes agenta_metadata keys from objects", () => { + const input = {name: "Alice", agenta_metadata: {source: "api"}} + const result = stripAgentaMetadataDeep(input) + expect(result).not.toHaveProperty("agenta_metadata") + expect((result as typeof input).name).toBe("Alice") + }) + + it("removes __agenta_metadata keys from objects", () => { + const input = {value: 1, __agenta_metadata: {}} + expect(stripAgentaMetadataDeep(input)).not.toHaveProperty("__agenta_metadata") + }) + + it("recursively strips metadata from nested objects", () => { + const input = { + user: {name: "Alice", agenta_metadata: {x: 1}}, + } + const result = stripAgentaMetadataDeep(input) as typeof input + expect(result.user).not.toHaveProperty("agenta_metadata") + expect(result.user.name).toBe("Alice") + }) + + it("strips metadata from objects inside arrays", () => { + const input = [{score: 5, agenta_metadata: {}}] + const result = stripAgentaMetadataDeep(input) as typeof input + expect(result[0]).not.toHaveProperty("agenta_metadata") + expect(result[0].score).toBe(5) + }) + + it("returns primitives unchanged", () => { + expect(stripAgentaMetadataDeep("hello")).toBe("hello") + expect(stripAgentaMetadataDeep(42)).toBe(42) + expect(stripAgentaMetadataDeep(null)).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// stripEnhancedWrappers +// --------------------------------------------------------------------------- + +describe("stripEnhancedWrappers", () => { + it("unwraps a simple {__id, __metadata, value} wrapper", () => { + const input = {__id: "x", __metadata: {}, value: "hello"} + expect(stripEnhancedWrappers(input)).toBe("hello") + }) + + it("strips __id and __metadata from plain objects (non-wrapper)", () => { + const input = {__id: "x", __metadata: {}, name: "Alice", age: 30} + const result = stripEnhancedWrappers(input) as {name: string; age: number} + expect(result).not.toHaveProperty("__id") + expect(result).not.toHaveProperty("__metadata") + expect(result.name).toBe("Alice") + expect(result.age).toBe(30) + }) + + it("recursively strips wrappers from nested objects", () => { + const input = { + user: {__id: "u1", __metadata: {}, name: "Alice"}, + } + const result = stripEnhancedWrappers(input) as {user: {name: string}} + expect(result.user).not.toHaveProperty("__id") + expect(result.user.name).toBe("Alice") + }) + + it("processes arrays recursively", () => { + const input = [ + {__id: "1", __metadata: {}, value: 1}, + {__id: "2", __metadata: {}, value: 2}, + ] + const result = stripEnhancedWrappers(input) as number[] + expect(result).toEqual([1, 2]) + }) + + it("returns null/undefined unchanged", () => { + expect(stripEnhancedWrappers(null)).toBeNull() + expect(stripEnhancedWrappers(undefined)).toBeUndefined() + }) + + it("returns primitives unchanged", () => { + expect(stripEnhancedWrappers("hello")).toBe("hello") + expect(stripEnhancedWrappers(42)).toBe(42) + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/formatters.test.ts b/web/packages/agenta-shared/tests/unit/formatters.test.ts new file mode 100644 index 0000000000..eb7bddca4e --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/formatters.test.ts @@ -0,0 +1,222 @@ +import {describe, expect, it} from "vitest" + +import { + createFormatter, + formatCompact, + formatCurrency, + formatLatency, + formatNumber, + formatPercent, + formatPreviewValue, + formatSignificant, + formatTokens, +} from "../../src/utils/formatters/formatters" + +// --------------------------------------------------------------------------- +// formatNumber +// --------------------------------------------------------------------------- + +describe("formatNumber", () => { + it("formats with locale thousand separators and 2 decimal places", () => { + expect(formatNumber(1234.567)).toBe("1,234.57") + }) + + it("returns '-' for null", () => expect(formatNumber(null)).toBe("-")) + it("returns '-' for undefined", () => expect(formatNumber(undefined)).toBe("-")) + + it("formats zero", () => expect(formatNumber(0)).toBe("0")) + it("formats negative numbers", () => expect(formatNumber(-1234)).toBe("-1,234")) +}) + +// --------------------------------------------------------------------------- +// formatCompact +// --------------------------------------------------------------------------- + +describe("formatCompact", () => { + it("formats thousands as K", () => expect(formatCompact(1500)).toBe("1.5K")) + it("formats millions as M", () => expect(formatCompact(1_500_000)).toBe("1.5M")) + it("returns '-' for null", () => expect(formatCompact(null)).toBe("-")) +}) + +// --------------------------------------------------------------------------- +// formatCurrency +// --------------------------------------------------------------------------- + +describe("formatCurrency", () => { + it("formats with dollar sign and 2 decimals for typical values", () => { + expect(formatCurrency(1234.56)).toBe("$1,234.56") + }) + + it("formats small values without trailing zeros (maximumFractionDigits: 6)", () => { + expect(formatCurrency(0.00123)).toBe("$0.00123") + }) + + it("returns '-' for null", () => expect(formatCurrency(null)).toBe("-")) +}) + +// --------------------------------------------------------------------------- +// formatLatency +// --------------------------------------------------------------------------- + +describe("formatLatency", () => { + it("formats sub-millisecond values in μs", () => { + expect(formatLatency(0.0001)).toBe("100μs") + }) + + it("formats millisecond-range values in ms", () => { + expect(formatLatency(0.5)).toBe("500ms") + }) + + it("formats second-range values in s", () => { + expect(formatLatency(2.5)).toBe("2.5s") + }) + + it("formats exactly 1 second", () => { + expect(formatLatency(1)).toBe("1s") + }) + + it("returns '-' for null", () => expect(formatLatency(null)).toBe("-")) + it("returns '-' for undefined", () => expect(formatLatency(undefined)).toBe("-")) +}) + +// --------------------------------------------------------------------------- +// formatTokens +// --------------------------------------------------------------------------- + +describe("formatTokens", () => { + it("formats values under 1000 as plain integers", () => { + expect(formatTokens(500)).toBe("500") + }) + + it("formats thousands as K with 1 decimal", () => { + expect(formatTokens(1500)).toBe("1.5K") + }) + + it("formats millions as M with 1 decimal", () => { + expect(formatTokens(1_500_000)).toBe("1.5M") + }) + + it("returns '-' for null", () => expect(formatTokens(null)).toBe("-")) +}) + +// --------------------------------------------------------------------------- +// formatPercent +// --------------------------------------------------------------------------- + +describe("formatPercent", () => { + it("formats decimal as percentage with 1 decimal for values >= 10%", () => { + expect(formatPercent(0.856)).toBe("85.6%") + }) + + it("formats small values with 2 decimal places", () => { + expect(formatPercent(0.001)).toBe("0.10%") + }) + + it("returns '100%' for values >= 99.95%", () => { + expect(formatPercent(1)).toBe("100%") + expect(formatPercent(0.9995)).toBe("100%") + }) + + it("returns '0%' for zero", () => { + expect(formatPercent(0)).toBe("0%") + }) + + it("treats negative values as 0%", () => { + expect(formatPercent(-0.1)).toBe("0%") + }) + + it("returns '-' for null", () => expect(formatPercent(null)).toBe("-")) +}) + +// --------------------------------------------------------------------------- +// formatSignificant +// --------------------------------------------------------------------------- + +describe("formatSignificant", () => { + it("formats values with significant-figure-aware decimals", () => { + // 1234: exponent=3 → decimals=max(0, 2-3)=0 → "1234" (integer, no rounding) + expect(formatSignificant(1234)).toBe("1234") + // 0.00456: exponent=-3 → decimals=max(0, 2-(-3))=5 → "0.00456" + expect(formatSignificant(0.00456)).toBe("0.00456") + }) + + it("returns '0' for zero", () => { + expect(formatSignificant(0)).toBe("0") + }) + + it("uses scientific notation for extreme values", () => { + const result = formatSignificant(1.5e12) + expect(result).toMatch(/e/) + }) + + it("returns '-' for null", () => expect(formatSignificant(null)).toBe("-")) +}) + +// --------------------------------------------------------------------------- +// formatPreviewValue +// --------------------------------------------------------------------------- + +describe("formatPreviewValue", () => { + it("wraps strings in quotes", () => { + expect(formatPreviewValue("hello")).toBe('"hello"') + }) + + it("truncates long strings and adds ellipsis", () => { + const long = "a".repeat(60) + const result = formatPreviewValue(long, 50) + expect(result).toBe(`"${"a".repeat(50)}..."`) + }) + + it("formats numbers as-is", () => { + expect(formatPreviewValue(123)).toBe("123") + }) + + it("formats booleans as-is", () => { + expect(formatPreviewValue(true)).toBe("true") + expect(formatPreviewValue(false)).toBe("false") + }) + + it("formats arrays with length", () => { + expect(formatPreviewValue([1, 2, 3])).toBe("[Array(3)]") + }) + + it("formats small objects with key names", () => { + expect(formatPreviewValue({a: 1, b: 2})).toBe("{a, b}") + }) + + it("truncates objects with more than 3 keys", () => { + const result = formatPreviewValue({a: 1, b: 2, c: 3, d: 4}) + expect(result).toBe("{a, b, c...}") + }) + + it("returns '(null)' for null", () => expect(formatPreviewValue(null)).toBe("(null)")) + it("returns '(undefined)' for undefined", () => + expect(formatPreviewValue(undefined)).toBe("(undefined)")) +}) + +// --------------------------------------------------------------------------- +// createFormatter +// --------------------------------------------------------------------------- + +describe("createFormatter", () => { + it("applies multiplier, prefix, suffix, and fixed decimals", () => { + const fmt = createFormatter({multiplier: 100, suffix: "%", decimals: 1}) + expect(fmt(0.856)).toBe("85.6%") + }) + + it("uses the custom fallback for null/undefined", () => { + const fmt = createFormatter({fallback: "n/a"}) + expect(fmt(null)).toBe("n/a") + expect(fmt(undefined)).toBe("n/a") + }) + + it("uses compact notation when compact: true", () => { + const fmt = createFormatter({compact: true}) + expect(fmt(1500)).toBe("1.5K") + }) + + it("prepends a prefix", () => { + const fmt = createFormatter({prefix: "$", decimals: 2}) + expect(fmt(10)).toBe("$10.00") + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/path-utils.test.ts b/web/packages/agenta-shared/tests/unit/path-utils.test.ts new file mode 100644 index 0000000000..57c875af13 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/path-utils.test.ts @@ -0,0 +1,166 @@ +import {describe, expect, it} from "vitest" + +import { + deleteValueAtPath, + getValueAtPath, + hasValueAtPath, + setValueAtPath, +} from "../../src/utils/pathUtils" + +// --------------------------------------------------------------------------- +// getValueAtPath +// --------------------------------------------------------------------------- + +describe("getValueAtPath — basic object navigation", () => { + const data = {user: {profile: {name: "Alice", age: 30}}} + + it("retrieves a deeply nested value", () => { + expect(getValueAtPath(data, ["user", "profile", "name"])).toBe("Alice") + }) + + it("returns the root when the path is empty", () => { + expect(getValueAtPath(data, [])).toBe(data) + }) + + it("returns undefined for a missing key", () => { + expect(getValueAtPath(data, ["user", "missing"])).toBeUndefined() + }) + + it("returns undefined when traversal hits null", () => { + expect(getValueAtPath({a: null}, ["a", "b"])).toBeUndefined() + }) +}) + +describe("getValueAtPath — array indexing", () => { + it("accesses array elements by numeric index", () => { + expect(getValueAtPath([10, 20, 30], [1])).toBe(20) + }) + + it("accesses array elements by string index", () => { + expect(getValueAtPath([10, 20, 30], ["2"])).toBe(30) + }) + + it("returns undefined for out-of-bounds index", () => { + expect(getValueAtPath([10, 20], [5])).toBeUndefined() + }) + + it("navigates mixed array/object paths", () => { + const data = {items: [{id: "a"}, {id: "b"}]} + expect(getValueAtPath(data, ["items", 1, "id"])).toBe("b") + }) +}) + +describe("getValueAtPath — JSON string traversal", () => { + it("parses a JSON string and continues traversal", () => { + const data = {messages: '{"content": "hello"}'} + expect(getValueAtPath(data, ["messages", "content"])).toBe("hello") + }) + + it("returns undefined when the string is not valid JSON", () => { + const data = {messages: "not json"} + expect(getValueAtPath(data, ["messages", "content"])).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// setValueAtPath +// --------------------------------------------------------------------------- + +describe("setValueAtPath — object mutation (immutable)", () => { + it("sets a nested value without mutating the original", () => { + const data = {user: {name: "Alice"}} + const updated = setValueAtPath(data, ["user", "name"], "Bob") + expect((updated as typeof data).user.name).toBe("Bob") + expect(data.user.name).toBe("Alice") + }) + + it("creates intermediate objects for new paths", () => { + const data = {} + const updated = setValueAtPath(data, ["a", "b"], 42) as {a: {b: number}} + expect(updated.a.b).toBe(42) + }) + + it("replaces the root when path is empty", () => { + expect(setValueAtPath({a: 1}, [], "new")).toBe("new") + }) +}) + +describe("setValueAtPath — array mutation (immutable)", () => { + it("sets an array element by index", () => { + const arr = [1, 2, 3] + const updated = setValueAtPath(arr, [1], 99) as number[] + expect(updated[1]).toBe(99) + expect(arr[1]).toBe(2) + }) + + it("handles nested array+object paths", () => { + const data = {items: [{id: "a"}, {id: "b"}]} + const updated = setValueAtPath(data, ["items", 0, "id"], "z") as typeof data + expect(updated.items[0].id).toBe("z") + expect(updated.items[1].id).toBe("b") + }) +}) + +describe("setValueAtPath — JSON string re-serialisation", () => { + it("parses a JSON string, sets the value, and re-stringifies", () => { + const data = {messages: '{"content": "hello"}'} + const updated = setValueAtPath(data, ["messages", "content"], "world") as typeof data + expect(updated.messages).toBe('{"content":"world"}') + }) +}) + +// --------------------------------------------------------------------------- +// deleteValueAtPath +// --------------------------------------------------------------------------- + +describe("deleteValueAtPath — object", () => { + it("removes a key from a nested object (immutable)", () => { + const data = {user: {name: "Alice", age: 30}} + const updated = deleteValueAtPath(data, ["user", "age"]) as typeof data + expect(updated.user).not.toHaveProperty("age") + expect(updated.user.name).toBe("Alice") + expect(data.user.age).toBe(30) + }) + + it("returns data unchanged when path is empty", () => { + const data = {a: 1} + expect(deleteValueAtPath(data, [])).toBe(data) + }) +}) + +describe("deleteValueAtPath — array", () => { + it("removes an element from an array by index", () => { + const result = deleteValueAtPath([10, 20, 30], [1]) as number[] + expect(result).toEqual([10, 30]) + }) +}) + +// --------------------------------------------------------------------------- +// hasValueAtPath +// --------------------------------------------------------------------------- + +describe("hasValueAtPath", () => { + it("returns true when the key exists", () => { + expect(hasValueAtPath({a: {b: 1}}, ["a", "b"])).toBe(true) + }) + + it("returns false when the key is missing", () => { + expect(hasValueAtPath({a: {}}, ["a", "missing"])).toBe(false) + }) + + it("returns false when a parent is null", () => { + expect(hasValueAtPath({a: null}, ["a", "b"])).toBe(false) + }) + + it("returns true for valid array index", () => { + expect(hasValueAtPath([10, 20, 30], [2])).toBe(true) + }) + + it("returns false for out-of-bounds array index", () => { + expect(hasValueAtPath([10, 20], [5])).toBe(false) + }) + + it("returns true for the root when path is empty and data is defined", () => { + expect(hasValueAtPath({a: 1}, [])).toBe(true) + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/slug.test.ts b/web/packages/agenta-shared/tests/unit/slug.test.ts new file mode 100644 index 0000000000..02369b9644 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/slug.test.ts @@ -0,0 +1,234 @@ +import {describe, expect, it} from "vitest" + +import { + generateSlugWithExistingSuffix, + generateSlugWithSuffix, + getSlugSuffix, + isValidSlug, + regenerateSlugSuffix, + slugifyName, + stripSlugSuffix, +} from "../../src/utils/slug" +import { + buildGatewayToolSlug, + isGatewayToolSlug, + parseGatewayToolSlug, +} from "../../src/utils/toolSlug" + +// --------------------------------------------------------------------------- +// slugifyName +// --------------------------------------------------------------------------- + +describe("slugifyName", () => { + it("lowercases and trims", () => { + expect(slugifyName(" Hello World ")).toBe("hello-world") + }) + + it("replaces spaces with hyphens", () => { + expect(slugifyName("my app name")).toBe("my-app-name") + }) + + it("collapses multiple spaces into one hyphen", () => { + expect(slugifyName("foo bar")).toBe("foo-bar") + }) + + it("strips leading and trailing hyphens", () => { + expect(slugifyName("-leading")).toBe("leading") + expect(slugifyName("trailing-")).toBe("trailing") + }) + + it("preserves allowed chars: digits, underscore, dot, hyphen", () => { + expect(slugifyName("my_app.v2-beta")).toBe("my_app.v2-beta") + }) + + it("removes disallowed special characters", () => { + expect(slugifyName("hello! @world#")).toBe("hello-world") + }) + + it("returns empty string for a blank input", () => { + expect(slugifyName("")).toBe("") + expect(slugifyName(" ")).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// generateSlugWithSuffix +// --------------------------------------------------------------------------- + +describe("generateSlugWithSuffix", () => { + it("produces -<4 chars> format", () => { + const slug = generateSlugWithSuffix("My App") + expect(slug).toMatch(/^my-app-[a-z0-9]{4}$/) + }) + + it("falls back to 'resource' when name slugifies to empty", () => { + const slug = generateSlugWithSuffix("!!!!") + expect(slug).toMatch(/^resource-[a-z0-9]{4}$/) + }) + + it("produces different slugs on repeated calls (randomness)", () => { + const slugs = new Set(Array.from({length: 10}, () => generateSlugWithSuffix("app"))) + // With 36^4 = ~1.7M possibilities, collision probability over 10 draws is negligible + expect(slugs.size).toBeGreaterThan(1) + }) +}) + +// --------------------------------------------------------------------------- +// generateSlugWithExistingSuffix +// --------------------------------------------------------------------------- + +describe("generateSlugWithExistingSuffix", () => { + it("appends the provided suffix to the slugified name", () => { + expect(generateSlugWithExistingSuffix("My App", "ab12")).toBe("my-app-ab12") + }) + + it("generates a new random suffix when suffix is null", () => { + const slug = generateSlugWithExistingSuffix("My App", null) + expect(slug).toMatch(/^my-app-[a-z0-9]{4}$/) + }) + + it("generates a new random suffix when suffix is undefined", () => { + const slug = generateSlugWithExistingSuffix("My App") + expect(slug).toMatch(/^my-app-[a-z0-9]{4}$/) + }) +}) + +// --------------------------------------------------------------------------- +// getSlugSuffix +// --------------------------------------------------------------------------- + +describe("getSlugSuffix", () => { + it("returns the 4-char suffix when present", () => { + expect(getSlugSuffix("my-app-ab12")).toBe("ab12") + }) + + it("returns null when the trailing segment is not exactly 4 chars", () => { + expect(getSlugSuffix("my-app-abc")).toBeNull() + expect(getSlugSuffix("my-app-abcde")).toBeNull() + }) + + it("returns null when there is no hyphen-separated suffix", () => { + expect(getSlugSuffix("myapp")).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// stripSlugSuffix +// --------------------------------------------------------------------------- + +describe("stripSlugSuffix", () => { + it("removes the 4-char suffix", () => { + expect(stripSlugSuffix("my-app-ab12")).toBe("my-app") + }) + + it("leaves the slug unchanged when no suffix is present", () => { + expect(stripSlugSuffix("myapp")).toBe("myapp") + expect(stripSlugSuffix("my-app-toolong")).toBe("my-app-toolong") + }) +}) + +// --------------------------------------------------------------------------- +// regenerateSlugSuffix +// --------------------------------------------------------------------------- + +describe("regenerateSlugSuffix", () => { + it("replaces the known suffix with a new random one", () => { + const slug = regenerateSlugSuffix("my-app-ab12", "ab12") + expect(slug).toMatch(/^my-app-[a-z0-9]{4}$/) + // The new suffix should differ from the old one (probabilistically) + // We just assert the format is correct + }) + + it("appends a new suffix when the slug does not end with the given suffix", () => { + const slug = regenerateSlugSuffix("my-app", "other") + expect(slug).toMatch(/^my-app-[a-z0-9]{4}$/) + }) + + it("always produces a 4-char suffix", () => { + const slug = regenerateSlugSuffix("app-xyz1") + expect(slug).toMatch(/-[a-z0-9]{4}$/) + }) +}) + +// --------------------------------------------------------------------------- +// isValidSlug +// --------------------------------------------------------------------------- + +describe("isValidSlug", () => { + it.each(["a", "abc", "my-app", "my_app", "app.v2", "app-v2-ab12"])( + "returns true for valid slug %s", + (s) => expect(isValidSlug(s)).toBe(true), + ) + + it("returns false for empty string", () => { + expect(isValidSlug("")).toBe(false) + }) + + it("returns false for slugs longer than 255 characters", () => { + expect(isValidSlug("a".repeat(256))).toBe(false) + }) + + it("returns false for double hyphens", () => { + expect(isValidSlug("my--app")).toBe(false) + }) + + it("returns false for double dots", () => { + expect(isValidSlug("my..app")).toBe(false) + }) + + it("returns false for slugs starting or ending with non-alphanumeric", () => { + expect(isValidSlug("-app")).toBe(false) + expect(isValidSlug("app-")).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// buildGatewayToolSlug / isGatewayToolSlug / parseGatewayToolSlug +// --------------------------------------------------------------------------- + +describe("buildGatewayToolSlug", () => { + it("builds the correct double-underscore format", () => { + expect(buildGatewayToolSlug("google", "gmail", "SEND_EMAIL", "my-connection")).toBe( + "tools__google__gmail__SEND_EMAIL__my-connection", + ) + }) +}) + +describe("isGatewayToolSlug", () => { + it("returns true for a valid gateway tool slug", () => { + expect(isGatewayToolSlug("tools__google__gmail__SEND__conn")).toBe(true) + }) + + it("returns false for a non-gateway slug", () => { + expect(isGatewayToolSlug("get_weather")).toBe(false) + expect(isGatewayToolSlug(undefined)).toBe(false) + }) +}) + +describe("parseGatewayToolSlug", () => { + it("parses all four parts correctly", () => { + const result = parseGatewayToolSlug("tools__google__gmail__SEND_EMAIL__my-conn") + expect(result).toEqual({ + provider: "google", + integration: "gmail", + action: "SEND_EMAIL", + connection: "my-conn", + }) + }) + + it("returns null for a slug with wrong number of parts", () => { + expect(parseGatewayToolSlug("tools__google__gmail")).toBeNull() + }) + + it("returns null for a slug that does not start with 'tools'", () => { + expect(parseGatewayToolSlug("nottools__a__b__c__d")).toBeNull() + }) + + it("returns null for undefined input", () => { + expect(parseGatewayToolSlug(undefined)).toBeNull() + }) + + it("returns null when any segment is empty", () => { + expect(parseGatewayToolSlug("tools__google____SEND__conn")).toBeNull() + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/template-variable.test.ts b/web/packages/agenta-shared/tests/unit/template-variable.test.ts new file mode 100644 index 0000000000..9fa5aafe9a --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/template-variable.test.ts @@ -0,0 +1,147 @@ +import {describe, expect, it} from "vitest" + +import { + extractTemplateExpression, + isValidTemplateVariable, + validateTemplateVariable, +} from "../../src/utils/templateVariable" + +// --------------------------------------------------------------------------- +// validateTemplateVariable — empty / malformed +// --------------------------------------------------------------------------- + +describe("validateTemplateVariable — empty / malformed", () => { + it("rejects an empty expression", () => { + const result = validateTemplateVariable("") + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/empty/i) + }) + + it("rejects expressions with consecutive dots (..)", () => { + expect(validateTemplateVariable("$.inputs..country").valid).toBe(false) + }) + + it("rejects expressions with consecutive slashes (//)", () => { + expect(validateTemplateVariable("/inputs//country").valid).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// validateTemplateVariable — JSONPath ($.) +// --------------------------------------------------------------------------- + +describe("validateTemplateVariable — JSONPath", () => { + it("accepts a well-formed JSONPath", () => { + expect(validateTemplateVariable("$.inputs.country").valid).toBe(true) + }) + + it("accepts bare '$' (whole context shorthand)", () => { + expect(validateTemplateVariable("$").valid).toBe(true) + }) + + it("rejects '$' (malformed root)", () => { + const result = validateTemplateVariable("$outputs.country") + expect(result.valid).toBe(false) + }) + + it("rejects '$.' with no field after the dot", () => { + expect(validateTemplateVariable("$.").valid).toBe(false) + }) + + it("accepts any root segment — does NOT validate against envelope slots (permissive)", () => { + // Per mustache QA principle: $.arbitrary is valid; runtime validates + expect(validateTemplateVariable("$.arbitrary_column").valid).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// validateTemplateVariable — JSON Pointer (/) +// --------------------------------------------------------------------------- + +describe("validateTemplateVariable — JSON Pointer", () => { + it("accepts a pointer rooted at a known envelope slot", () => { + expect(validateTemplateVariable("/inputs/country").valid).toBe(true) + expect(validateTemplateVariable("/outputs/result").valid).toBe(true) + }) + + it("rejects a multi-segment pointer with an unknown root slot", () => { + const result = validateTemplateVariable("/unknown/field") + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/unknown envelope slot/i) + }) + + it("includes a 'did-you-mean' suggestion for near-miss slot names", () => { + const result = validateTemplateVariable("/input/country") // 'input' ≈ 'inputs' + expect(result.valid).toBe(false) + expect(result.suggestion).toBe("inputs") + }) + + it("accepts a single-segment identifier-shaped pointer unconditionally (mustache close tag)", () => { + // e.g. {{/section}} — single segment, identifier-shaped → valid + expect(validateTemplateVariable("/section").valid).toBe(true) + }) + + it("rejects '/' with no segments", () => { + expect(validateTemplateVariable("/").valid).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// validateTemplateVariable — plain names / dot notation +// --------------------------------------------------------------------------- + +describe("validateTemplateVariable — plain names", () => { + it("accepts plain identifiers", () => { + expect(validateTemplateVariable("question").valid).toBe(true) + expect(validateTemplateVariable("my_variable").valid).toBe(true) + }) + + it("accepts dot-notation paths", () => { + expect(validateTemplateVariable("user.name").valid).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// isValidTemplateVariable +// --------------------------------------------------------------------------- + +describe("isValidTemplateVariable", () => { + it("returns true for a valid expression", () => { + expect(isValidTemplateVariable("$.inputs.country")).toBe(true) + }) + + it("returns false for an invalid expression", () => { + expect(isValidTemplateVariable("")).toBe(false) + expect(isValidTemplateVariable("$outputs.x")).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// extractTemplateExpression +// --------------------------------------------------------------------------- + +describe("extractTemplateExpression", () => { + it("strips {{ }} wrappers", () => { + expect(extractTemplateExpression("{{ $.inputs.country }}")).toBe("$.inputs.country") + }) + + it("strips {% %} wrappers", () => { + expect(extractTemplateExpression("{% if condition %}")).toBe("if condition") + }) + + it("strips {%- -%} wrappers (whitespace-trimming variants)", () => { + expect(extractTemplateExpression("{%- block -%}")).toBe("block") + }) + + it("strips {# #} comment wrappers", () => { + expect(extractTemplateExpression("{# comment #}")).toBe("comment") + }) + + it("returns the raw text when no wrapper is present", () => { + expect(extractTemplateExpression("plain")).toBe("plain") + }) + + it("returns empty string for empty input", () => { + expect(extractTemplateExpression("")).toBe("") + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/validators-and-ids.test.ts b/web/packages/agenta-shared/tests/unit/validators-and-ids.test.ts new file mode 100644 index 0000000000..92fc346e63 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/validators-and-ids.test.ts @@ -0,0 +1,138 @@ +import {describe, expect, it} from "vitest" + +import {isValidHttpUrl, isValidRegex, isValidUUID, validateUUID} from "../../src/utils/validators" +import {uuidToSpanId, uuidToTraceId} from "../../src/utils/traceIds" +import {removeTrailingSlash} from "../../src/utils/uriUtils" + +// --------------------------------------------------------------------------- +// isValidUUID +// --------------------------------------------------------------------------- + +describe("isValidUUID", () => { + it.each([ + "123e4567-e89b-12d3-a456-426614174000", + "00000000-0000-0000-0000-000000000000", + "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + ])("returns true for valid UUID %s", (uuid) => { + expect(isValidUUID(uuid)).toBe(true) + }) + + it.each([ + "", + "not-a-uuid", + "123e4567-e89b-12d3-a456", + "123e4567-e89b-12d3-a456-42661417400Z", + "123e4567e89b12d3a456426614174000", + ])("returns false for invalid input %s", (input) => { + expect(isValidUUID(input)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// validateUUID +// --------------------------------------------------------------------------- + +describe("validateUUID", () => { + it("does not throw for a valid UUID", () => { + expect(() => validateUUID("123e4567-e89b-12d3-a456-426614174000", "id")).not.toThrow() + }) + + it("throws with a descriptive message for an invalid UUID", () => { + expect(() => validateUUID("not-valid", "userId")).toThrow( + "Invalid userId: must be a valid UUID", + ) + }) +}) + +// --------------------------------------------------------------------------- +// isValidHttpUrl +// --------------------------------------------------------------------------- + +describe("isValidHttpUrl", () => { + it.each(["http://example.com", "https://example.com/path?q=1"])("returns true for %s", (url) => + expect(isValidHttpUrl(url)).toBe(true), + ) + + it.each(["ftp://example.com", "not-a-url", "", "javascript:alert(1)"])( + "returns false for %s", + (url) => expect(isValidHttpUrl(url)).toBe(false), + ) +}) + +// --------------------------------------------------------------------------- +// isValidRegex +// --------------------------------------------------------------------------- + +describe("isValidRegex", () => { + it.each(["^[a-z]+$", "\\d+", "(foo|bar)", ".*"])("returns true for valid regex %s", (re) => + expect(isValidRegex(re)).toBe(true), + ) + + it.each(["[invalid", "(unclosed", "*bad"])("returns false for invalid regex %s", (re) => { + expect(isValidRegex(re)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// uuidToTraceId +// --------------------------------------------------------------------------- + +describe("uuidToTraceId", () => { + it("strips dashes from a UUID", () => { + expect(uuidToTraceId("123e4567-e89b-12d3-a456-426614174000")).toBe( + "123e4567e89b12d3a456426614174000", + ) + }) + + it("returns undefined for undefined input", () => { + expect(uuidToTraceId(undefined)).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + expect(uuidToTraceId("")).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// uuidToSpanId +// --------------------------------------------------------------------------- + +describe("uuidToSpanId", () => { + it("returns the last 16 hex chars of the stripped UUID", () => { + // UUID: 123e4567-e89b-12d3-a456-426614174000 + // Full hex: 123e4567e89b12d3a456426614174000 (32 chars) + // Last 16: a456426614174000 + expect(uuidToSpanId("123e4567-e89b-12d3-a456-426614174000")).toBe("a456426614174000") + }) + + it("returns undefined for undefined input", () => { + expect(uuidToSpanId(undefined)).toBeUndefined() + }) + + it("span ID length is always 16", () => { + const spanId = uuidToSpanId("ffffffff-ffff-ffff-ffff-ffffffffffff") + expect(spanId).toHaveLength(16) + }) +}) + +// --------------------------------------------------------------------------- +// removeTrailingSlash +// --------------------------------------------------------------------------- + +describe("removeTrailingSlash", () => { + it("removes a trailing slash", () => { + expect(removeTrailingSlash("http://example.com/")).toBe("http://example.com") + }) + + it("leaves a URI without trailing slash unchanged", () => { + expect(removeTrailingSlash("http://example.com")).toBe("http://example.com") + }) + + it("removes only the last slash, not interior ones", () => { + expect(removeTrailingSlash("http://example.com/path/")).toBe("http://example.com/path") + }) + + it("handles empty string", () => { + expect(removeTrailingSlash("")).toBe("") + }) +}) diff --git a/web/packages/agenta-shared/vitest.config.ts b/web/packages/agenta-shared/vitest.config.ts new file mode 100644 index 0000000000..a9a2cfed1d --- /dev/null +++ b/web/packages/agenta-shared/vitest.config.ts @@ -0,0 +1,19 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/unit/**/*.test.ts"], + environment: "node", + reporters: ["default", "junit"], + outputFile: { + junit: "./test-results/junit.xml", + }, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/index.ts"], + reporter: ["text", "lcov", "json-summary"], + reportsDirectory: "./coverage", + }, + }, +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a93061a109..58efcbad6f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -693,9 +693,15 @@ importers: '@types/node': specifier: ^20.8.10 version: 20.19.39 + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.6(vitest@4.1.6) typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: ^4.1.4 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4)) packages/agenta-annotation-ui: dependencies: @@ -1213,9 +1219,15 @@ importers: '@types/react': specifier: ^19.0.10 version: 19.2.14 + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.6(vitest@4.1.6) typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: ^4.1.4 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4)) packages/agenta-ui: dependencies: From a1ded4a202fb99503afa823f73b99027c4fad2bb Mon Sep 17 00:00:00 2001 From: Kaosiso Ezealigo Date: Wed, 3 Jun 2026 11:51:31 +0200 Subject: [PATCH 2/4] chore(test): gitignore test-results and coverage dirs for shared and annotation packages Co-Authored-By: Claude Sonnet 4.6 --- web/packages/agenta-annotation/.gitignore | 3 + .../agenta-annotation/test-results/junit.xml | 163 -------- web/packages/agenta-shared/.gitignore | 3 + .../agenta-shared/test-results/junit.xml | 389 ------------------ 4 files changed, 6 insertions(+), 552 deletions(-) create mode 100644 web/packages/agenta-annotation/.gitignore delete mode 100644 web/packages/agenta-annotation/test-results/junit.xml create mode 100644 web/packages/agenta-shared/.gitignore delete mode 100644 web/packages/agenta-shared/test-results/junit.xml diff --git a/web/packages/agenta-annotation/.gitignore b/web/packages/agenta-annotation/.gitignore new file mode 100644 index 0000000000..96d253c48e --- /dev/null +++ b/web/packages/agenta-annotation/.gitignore @@ -0,0 +1,3 @@ +# Generated by Vitest — do not commit +test-results/ +coverage/ diff --git a/web/packages/agenta-annotation/test-results/junit.xml b/web/packages/agenta-annotation/test-results/junit.xml deleted file mode 100644 index 85cdaef2d1..0000000000 --- a/web/packages/agenta-annotation/test-results/junit.xml +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/packages/agenta-shared/.gitignore b/web/packages/agenta-shared/.gitignore new file mode 100644 index 0000000000..96d253c48e --- /dev/null +++ b/web/packages/agenta-shared/.gitignore @@ -0,0 +1,3 @@ +# Generated by Vitest — do not commit +test-results/ +coverage/ diff --git a/web/packages/agenta-shared/test-results/junit.xml b/web/packages/agenta-shared/test-results/junit.xml deleted file mode 100644 index ba991a034e..0000000000 --- a/web/packages/agenta-shared/test-results/junit.xml +++ /dev/null @@ -1,389 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 6b8a8022bcca0b527d36baa000c9457f3054bf30 Mon Sep 17 00:00:00 2001 From: Kaosiso Ezealigo Date: Wed, 3 Jun 2026 12:37:23 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(test):=20address=20PR=20review=20commen?= =?UTF-8?q?ts=20=E2=80=94=20typed=20fixtures,=20falsy-root=20coverage,=20t?= =?UTF-8?q?emplate-variable=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `as any` fixture casts with `as unknown as T` in annotation tests - Fix incorrect Annotation import source in testset-sync (now from @agenta/entities/annotation) - Add Testcase type import and remove all as-any call-site casts in testset-sync - Add falsy-root short-circuit tests for getValueAtPath (0, false, "", null) - Realign template-variable tests to the strict envelope-slot behavior on main Co-Authored-By: Claude Sonnet 4.6 --- .../unit/annotation-form-helpers.test.ts | 10 +++-- .../tests/unit/testset-sync.test.ts | 31 +++++++------- .../tests/unit/path-utils.test.ts | 18 ++++++++ .../tests/unit/template-variable.test.ts | 41 ++++++++----------- 4 files changed, 58 insertions(+), 42 deletions(-) diff --git a/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts b/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts index d0a246ce6c..f796a10c8c 100644 --- a/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts +++ b/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts @@ -77,32 +77,34 @@ import { getOutputsSchema, isEmptyValue, } from "../../src/state/controllers/annotationFormController" +import type {Annotation} from "@agenta/entities/annotation" +import type {Workflow} from "@agenta/entities/workflow" // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function makeWorkflow(schemaProperties: Record = {}) { +function makeWorkflow(schemaProperties: Record = {}): Workflow { // resolveOutputSchema is mocked to return its input, // so we set data to the schema shape directly. return { data: {properties: schemaProperties}, slug: "test-evaluator", id: "wf-1", - } as any + } as unknown as Workflow } function makeAnnotation( outputs: Record, references?: {evaluator?: {slug?: string}}, -) { +): Annotation { return { trace_id: "trace-1", span_id: "span-1", data: {outputs}, references, meta: {}, - } as any + } as unknown as Annotation } beforeEach(() => { diff --git a/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts b/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts index 598c60708d..4c7ce5c783 100644 --- a/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts +++ b/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts @@ -7,7 +7,8 @@ import {describe, expect, it} from "vitest" -import type {Annotation} from "../../src/state/testsetSync" +import type {Annotation} from "@agenta/entities/annotation" +import type {Testcase} from "@agenta/entities/testcase" import { buildTestcaseExportRows, buildTestsetSyncOperations, @@ -475,8 +476,8 @@ describe("buildTraceTestsetRows", () => { describe("buildTestcaseExportRows", () => { const evaluator = {slug: "quality", workflowId: "wf-q"} - function makeTestcase(id: string, testsetId: string) { - return {id, testset_id: testsetId, data: {prompt: "hello"}} + function makeTestcase(id: string, testsetId: string): Testcase { + return {id, testset_id: testsetId, data: {prompt: "hello"}} as unknown as Testcase } it("builds a row when annotation data exists for the testcase", () => { @@ -487,7 +488,7 @@ describe("buildTestcaseExportRows", () => { }) const rows = buildTestcaseExportRows({ scenarioIds: ["s-1"], - testcasesByScenarioId: new Map([["s-1", makeTestcase("tc-1", "ts-1") as any]]), + testcasesByScenarioId: new Map([["s-1", makeTestcase("tc-1", "ts-1")]]), annotationsByTestcaseId: new Map([["tc-1", [ann]]]), evaluators: [evaluator], queueId: "q-1", @@ -495,7 +496,7 @@ describe("buildTestcaseExportRows", () => { expect(rows).toHaveLength(1) expect(rows[0].testcaseId).toBe("tc-1") expect(rows[0].testsetId).toBe("ts-1") - expect((rows[0].data as any).quality).toMatchObject({score: 8}) + expect((rows[0].data as Record).quality).toMatchObject({score: 8}) }) it("skips a scenario with no testcase mapping", () => { @@ -512,7 +513,7 @@ describe("buildTestcaseExportRows", () => { it("skips a testcase with no annotations", () => { const rows = buildTestcaseExportRows({ scenarioIds: ["s-1"], - testcasesByScenarioId: new Map([["s-1", makeTestcase("tc-1", "ts-1") as any]]), + testcasesByScenarioId: new Map([["s-1", makeTestcase("tc-1", "ts-1")]]), annotationsByTestcaseId: new Map([["tc-1", []]]), evaluators: [evaluator], queueId: "q-1", @@ -528,8 +529,8 @@ describe("buildTestcaseExportRows", () => { describe("buildTestsetSyncPreview", () => { const evaluator = {slug: "quality", workflowId: "wf-q"} - function makeTestcase(id: string, testsetId: string) { - return {id, testset_id: testsetId, data: {}} + function makeTestcase(id: string, testsetId: string): Testcase { + return {id, testset_id: testsetId, data: {}} as unknown as Testcase } function makeQueueAnn(traceId = "trace-1") { @@ -559,7 +560,7 @@ describe("buildTestsetSyncPreview", () => { const preview = buildTestsetSyncPreview({ queueId: "q-1", completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], - testcasesById: new Map([["tc-1", {id: "tc-1", data: {}} as any]]), + testcasesById: new Map([["tc-1", {id: "tc-1", data: {}} as unknown as Testcase]]), annotationsByTestcaseId: new Map(), evaluators: [evaluator], latestRevisionIdsByTestsetId: new Map(), @@ -572,7 +573,7 @@ describe("buildTestsetSyncPreview", () => { const preview = buildTestsetSyncPreview({ queueId: "q-1", completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], - testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1")]]), annotationsByTestcaseId: new Map([["tc-1", [ann]]]), evaluators: [evaluator], latestRevisionIdsByTestsetId: new Map(), // ts-1 has no revision @@ -585,7 +586,7 @@ describe("buildTestsetSyncPreview", () => { const preview = buildTestsetSyncPreview({ queueId: "q-1", completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], - testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1")]]), annotationsByTestcaseId: new Map([["tc-1", [ann]]]), evaluators: [evaluator], latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), @@ -604,7 +605,7 @@ describe("buildTestsetSyncPreview", () => { const preview = buildTestsetSyncPreview({ queueId: "q-1", completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], - testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1")]]), annotationsByTestcaseId: new Map([["tc-1", [ann1, ann2]]]), evaluators: [evaluator], latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), @@ -624,8 +625,8 @@ describe("buildTestsetSyncPreview", () => { {scenarioId: "s-2", testcaseId: "tc-2"}, ], testcasesById: new Map([ - ["tc-1", makeTestcase("tc-1", "ts-1") as any], - ["tc-2", makeTestcase("tc-2", "ts-1") as any], + ["tc-1", makeTestcase("tc-1", "ts-1")], + ["tc-2", makeTestcase("tc-2", "ts-1")], ]), annotationsByTestcaseId: new Map([ ["tc-1", [ann1]], @@ -648,7 +649,7 @@ describe("buildTestsetSyncPreview", () => { const preview = buildTestsetSyncPreview({ queueId: "q-1", completedScenarios: [{scenarioId: "s-1", testcaseId: "tc-1"}], - testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1") as any]]), + testcasesById: new Map([["tc-1", makeTestcase("tc-1", "ts-1")]]), annotationsByTestcaseId: new Map([["tc-1", [annNoOutputs]]]), evaluators: [evaluator], latestRevisionIdsByTestsetId: new Map([["ts-1", "rev-1"]]), diff --git a/web/packages/agenta-shared/tests/unit/path-utils.test.ts b/web/packages/agenta-shared/tests/unit/path-utils.test.ts index 57c875af13..b330ac54c3 100644 --- a/web/packages/agenta-shared/tests/unit/path-utils.test.ts +++ b/web/packages/agenta-shared/tests/unit/path-utils.test.ts @@ -31,6 +31,24 @@ describe("getValueAtPath — basic object navigation", () => { }) }) +describe("getValueAtPath — falsy root short-circuit", () => { + it("returns 0 immediately (falsy root, path ignored)", () => { + expect(getValueAtPath(0, ["a"])).toBe(0) + }) + + it("returns false immediately (falsy root, path ignored)", () => { + expect(getValueAtPath(false, ["a"])).toBe(false) + }) + + it("returns empty string immediately (falsy root, path ignored)", () => { + expect(getValueAtPath("", ["a"])).toBe("") + }) + + it("returns null immediately (falsy root, path ignored)", () => { + expect(getValueAtPath(null, ["a"])).toBeNull() + }) +}) + describe("getValueAtPath — array indexing", () => { it("accesses array elements by numeric index", () => { expect(getValueAtPath([10, 20, 30], [1])).toBe(20) diff --git a/web/packages/agenta-shared/tests/unit/template-variable.test.ts b/web/packages/agenta-shared/tests/unit/template-variable.test.ts index 9fa5aafe9a..edcee7157d 100644 --- a/web/packages/agenta-shared/tests/unit/template-variable.test.ts +++ b/web/packages/agenta-shared/tests/unit/template-variable.test.ts @@ -27,30 +27,30 @@ describe("validateTemplateVariable — empty / malformed", () => { }) // --------------------------------------------------------------------------- -// validateTemplateVariable — JSONPath ($.) +// validateTemplateVariable — JSONPath ($) // --------------------------------------------------------------------------- describe("validateTemplateVariable — JSONPath", () => { - it("accepts a well-formed JSONPath", () => { - expect(validateTemplateVariable("$.inputs.country").valid).toBe(true) + it("rejects bare '$' (no envelope slot after root)", () => { + // On main: tokens after stripping '$.' are empty → invalid + expect(validateTemplateVariable("$").valid).toBe(false) }) - it("accepts bare '$' (whole context shorthand)", () => { - expect(validateTemplateVariable("$").valid).toBe(true) + it("accepts a well-formed JSONPath rooted at a known slot", () => { + expect(validateTemplateVariable("$.inputs.country").valid).toBe(true) + expect(validateTemplateVariable("$.outputs.result").valid).toBe(true) }) - it("rejects '$' (malformed root)", () => { - const result = validateTemplateVariable("$outputs.country") + it("rejects a JSONPath whose root is not a known envelope slot", () => { + const result = validateTemplateVariable("$.arbitrary_column") expect(result.valid).toBe(false) + expect(result.reason).toMatch(/unknown envelope slot/i) }) - it("rejects '$.' with no field after the dot", () => { - expect(validateTemplateVariable("$.").valid).toBe(false) - }) - - it("accepts any root segment — does NOT validate against envelope slots (permissive)", () => { - // Per mustache QA principle: $.arbitrary is valid; runtime validates - expect(validateTemplateVariable("$.arbitrary_column").valid).toBe(true) + it("includes a 'did-you-mean' suggestion for near-miss slot names", () => { + const result = validateTemplateVariable("$.input.country") // 'input' ≈ 'inputs' + expect(result.valid).toBe(false) + expect(result.suggestion).toBe("inputs") }) }) @@ -64,23 +64,18 @@ describe("validateTemplateVariable — JSON Pointer", () => { expect(validateTemplateVariable("/outputs/result").valid).toBe(true) }) - it("rejects a multi-segment pointer with an unknown root slot", () => { - const result = validateTemplateVariable("/unknown/field") + it("rejects a pointer with an unknown root slot", () => { + const result = validateTemplateVariable("/section") expect(result.valid).toBe(false) expect(result.reason).toMatch(/unknown envelope slot/i) }) it("includes a 'did-you-mean' suggestion for near-miss slot names", () => { - const result = validateTemplateVariable("/input/country") // 'input' ≈ 'inputs' + const result = validateTemplateVariable("/input/country") expect(result.valid).toBe(false) expect(result.suggestion).toBe("inputs") }) - it("accepts a single-segment identifier-shaped pointer unconditionally (mustache close tag)", () => { - // e.g. {{/section}} — single segment, identifier-shaped → valid - expect(validateTemplateVariable("/section").valid).toBe(true) - }) - it("rejects '/' with no segments", () => { expect(validateTemplateVariable("/").valid).toBe(false) }) @@ -112,7 +107,7 @@ describe("isValidTemplateVariable", () => { it("returns false for an invalid expression", () => { expect(isValidTemplateVariable("")).toBe(false) - expect(isValidTemplateVariable("$outputs.x")).toBe(false) + expect(isValidTemplateVariable("$.unknown_slot")).toBe(false) }) }) From 1b80a4e6180e550918936862b56b902e23f60549 Mon Sep 17 00:00:00 2001 From: Kaosiso Ezealigo Date: Fri, 5 Jun 2026 11:56:33 +0200 Subject: [PATCH 4/4] test(@agenta/shared): align template-variable tests with permissive JSONPath policy The release/v0.102.0 merge updated validateTemplateVariable to accept any well-formed $.x expression without checking against known envelope slots (post-mustache QA: slot mismatches surface as API errors, not UI errors). Five tests still asserted the old strict behavior and were failing. Updated them to match the intentional permissive policy documented in the source. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/unit/template-variable.test.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/web/packages/agenta-shared/tests/unit/template-variable.test.ts b/web/packages/agenta-shared/tests/unit/template-variable.test.ts index edcee7157d..40075febc9 100644 --- a/web/packages/agenta-shared/tests/unit/template-variable.test.ts +++ b/web/packages/agenta-shared/tests/unit/template-variable.test.ts @@ -31,9 +31,9 @@ describe("validateTemplateVariable — empty / malformed", () => { // --------------------------------------------------------------------------- describe("validateTemplateVariable — JSONPath", () => { - it("rejects bare '$' (no envelope slot after root)", () => { - // On main: tokens after stripping '$.' are empty → invalid - expect(validateTemplateVariable("$").valid).toBe(false) + it("accepts bare '$' (whole-context compact JSON)", () => { + // Bare '$' resolves the whole context object — valid per the runtime contract. + expect(validateTemplateVariable("$").valid).toBe(true) }) it("accepts a well-formed JSONPath rooted at a known slot", () => { @@ -41,16 +41,19 @@ describe("validateTemplateVariable — JSONPath", () => { expect(validateTemplateVariable("$.outputs.result").valid).toBe(true) }) - it("rejects a JSONPath whose root is not a known envelope slot", () => { + it("accepts a JSONPath with an unknown root (permissive — root becomes a testcase column)", () => { + // Per post-mustache QA: any well-formed '$.x' is valid; slot mismatches + // surface as runtime errors from the API, not UI errors. const result = validateTemplateVariable("$.arbitrary_column") - expect(result.valid).toBe(false) - expect(result.reason).toMatch(/unknown envelope slot/i) + expect(result.valid).toBe(true) }) - it("includes a 'did-you-mean' suggestion for near-miss slot names", () => { - const result = validateTemplateVariable("$.input.country") // 'input' ≈ 'inputs' - expect(result.valid).toBe(false) - expect(result.suggestion).toBe("inputs") + it("accepts a near-miss JSONPath without a typo suggestion (permissive)", () => { + // The JSONPath branch no longer emits 'did-you-mean' hints; the user's + // literal text wins and the root is treated as a testcase column name. + const result = validateTemplateVariable("$.input.country") + expect(result.valid).toBe(true) + expect(result.suggestion).toBeUndefined() }) }) @@ -64,13 +67,15 @@ describe("validateTemplateVariable — JSON Pointer", () => { expect(validateTemplateVariable("/outputs/result").valid).toBe(true) }) - it("rejects a pointer with an unknown root slot", () => { + it("accepts a single-segment identifier (may be a mustache section close tag)", () => { + // '/identifier' is ambiguous: it could be '{{/close}}' in mustache or a + // JSON Pointer to an envelope slot. Single-segment paths are accepted + // unconditionally; the runtime is the source of truth. const result = validateTemplateVariable("/section") - expect(result.valid).toBe(false) - expect(result.reason).toMatch(/unknown envelope slot/i) + expect(result.valid).toBe(true) }) - it("includes a 'did-you-mean' suggestion for near-miss slot names", () => { + it("rejects a multi-segment pointer with an unknown root slot", () => { const result = validateTemplateVariable("/input/country") expect(result.valid).toBe(false) expect(result.suggestion).toBe("inputs") @@ -107,7 +112,8 @@ describe("isValidTemplateVariable", () => { it("returns false for an invalid expression", () => { expect(isValidTemplateVariable("")).toBe(false) - expect(isValidTemplateVariable("$.unknown_slot")).toBe(false) + expect(isValidTemplateVariable("$foo")).toBe(false) // missing '.' after '$' + expect(isValidTemplateVariable("$.")).toBe(false) // trailing dot, no field }) })