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/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..f796a10c8c --- /dev/null +++ b/web/packages/agenta-annotation/tests/unit/annotation-form-helpers.test.ts @@ -0,0 +1,378 @@ +/** + * 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" +import type {Annotation} from "@agenta/entities/annotation" +import type {Workflow} from "@agenta/entities/workflow" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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 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 unknown as Annotation +} + +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..4c7ce5c783 --- /dev/null +++ b/web/packages/agenta-annotation/tests/unit/testset-sync.test.ts @@ -0,0 +1,660 @@ +/** + * 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 "@agenta/entities/annotation" +import type {Testcase} from "@agenta/entities/testcase" +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): Testcase { + return {id, testset_id: testsetId, data: {prompt: "hello"}} as unknown as Testcase + } + + 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")]]), + 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 Record).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")]]), + 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): Testcase { + return {id, testset_id: testsetId, data: {}} as unknown as Testcase + } + + 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 unknown as Testcase]]), + 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")]]), + 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")]]), + 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")]]), + 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")], + ["tc-2", makeTestcase("tc-2", "ts-1")], + ]), + 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")]]), + 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 index a9a2cfed1d..92bca1ab9d 100644 --- a/web/packages/agenta-annotation/vitest.config.ts +++ b/web/packages/agenta-annotation/vitest.config.ts @@ -1,6 +1,15 @@ +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", 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/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/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..b330ac54c3 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/path-utils.test.ts @@ -0,0 +1,184 @@ +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 — 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) + }) + + 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..40075febc9 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/template-variable.test.ts @@ -0,0 +1,148 @@ +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 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", () => { + expect(validateTemplateVariable("$.inputs.country").valid).toBe(true) + expect(validateTemplateVariable("$.outputs.result").valid).toBe(true) + }) + + 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(true) + }) + + 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() + }) +}) + +// --------------------------------------------------------------------------- +// 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("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(true) + }) + + 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") + }) + + 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("$foo")).toBe(false) // missing '.' after '$' + expect(isValidTemplateVariable("$.")).toBe(false) // trailing dot, no field + }) +}) + +// --------------------------------------------------------------------------- +// 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 7fc761e2d7..f4003d76db 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1222,9 +1222,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: