Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/apollo-vertex/app/templates/solution-tests/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ interface SolutionTestsConfig {
passThreshold?: number;
/** Show the Expected/Actual input panels in run-result details. Defaults to false (outputs only). */
showInputs?: boolean;
/** Typed telemetry callback. Fires a named event when each action is triggered; a no-op unless supplied. */
track?: TrackSolutionTestEvent;
}
```

Expand All @@ -110,6 +112,42 @@ render the dumb `SolutionTestsView`, which takes all data and callbacks via
props. It must still be wrapped in `SolutionTestsProvider` (the view reads
`config` from context via `useSolutionTestsConfig`), as the preview above does.

## Telemetry

The action hooks emit a typed telemetry event when the user triggers each action
(on intent, not on success). The template only *names* the events. It never ships an analytics backend. Pass a
`track` callback through `config` to route them wherever you collect analytics.
Omit it and tracking is a no-op. Because the events fire from the mutation hooks
(`useRunTests`, `useCreateTest`, and friends), only the smart container reports
them. A bare `SolutionTestsView` wired to your own handlers does not.
Comment on lines +120 to +122

```tsx
const config: SolutionTestsConfig = {
subjectColumns,
track: (event, properties) => analytics.track(event, properties),
};
```

`SolutionTestEventMap` is the single source of truth for the event contract, so
the callback is fully typed: `properties` is narrowed to the payload of the
`event` you pass. Compose the map into your own app's event map to keep the
wiring type-safe end to end.

| Event | Payload | Fired when |
| ------------------------------------ | -------------------------------------------------- | --------------------------------- |
| `VS.SolutionTest.Run` | `{ mode: "all" \| "test" \| "selected", testCount }` | A test run is started |
| `VS.SolutionTest.Created` | `{ subjectId }` | A test is created from a subject |
| `VS.SolutionTest.ActiveToggled` | `{ testId, isActive }` | A test is enabled or disabled |
| `VS.SolutionTest.Deleted` | `{ testId }` | A test is deleted |
| `VS.SolutionTest.BatchForceStopped` | `{ batchId }` | A batch run is force-stopped |
| `VS.SolutionTest.RunForceStopped` | `{ runId }` | A single run is force-stopped |
| `VS.SolutionTest.JobAdopted` | `{ resultId }` | A run result is adopted as baseline |
| `VS.SolutionTest.BaselineUpdated` | `{ resultId }` | A baseline is updated from a result |
| `VS.SolutionTest.BaselineRemoved` | `{ baselineId }` | A baseline is removed |

`SolutionTestEventMap`, `SolutionTestEventName`, and `TrackSolutionTestEvent`
are all exported from the package barrel.

## Saving a subject as a test

Alongside the full-page view, the package exports a standalone `SaveAsTestButton`
Expand Down Expand Up @@ -227,6 +265,9 @@ you prefer to supply your own data plumbing or render the view against a mock
- **Fixed setup** — evaluator labels, status labels, and the poll interval are
hard-coded in `constants.ts`; edit there to retarget a deployment. The pass
threshold defaults to `0.9` but is overridable via `config.passThreshold`.
- **Telemetry** — `config.track` receives a typed event when each action is
triggered; wire it to your analytics backend (see [Telemetry](#telemetry)).
Omit it for no tracking.
- **i18n** — framework strings use `react-i18next`; wrap your app in
`ApolloShell` (which initializes i18n via `LocaleProvider`) or provide your
own `I18nextProvider`.
58 changes: 58 additions & 0 deletions apps/apollo-vertex/registry/solution-tests/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,59 @@ import type { EvaluatorRenderers } from "./evaluators/registry";
import type { ProcessOutputRenderers } from "./outputs/registry";
import type { SolutionTest } from "./types";

/**
* Telemetry events the Solution Tests actions emit, with payloads. The template
* names the events; the host supplies `track` (below) to route them. Compose
* into a host event map with `interface … extends SolutionTestEventMap {}` (not
* an intersection) so a generic tracker's `Map[K]` indexing keeps working.
*/
export interface SolutionTestEventMap {
"VS.SolutionTest.Run": {
mode: "all" | "test" | "selected";
testCount: number;
};
"VS.SolutionTest.Created": { subjectId: string };
"VS.SolutionTest.ActiveToggled": { testId: string; isActive: boolean };
"VS.SolutionTest.Deleted": { testId: string };
"VS.SolutionTest.BatchForceStopped": { batchId: string };
"VS.SolutionTest.RunForceStopped": { runId: string };
"VS.SolutionTest.JobAdopted": { resultId: string };
"VS.SolutionTest.BaselineUpdated": { resultId: string };
"VS.SolutionTest.BaselineRemoved": { baselineId: string };

// UI interactions (no server request) — user navigation and output inspection.
"VS.SolutionTest.TabViewed": { tab: "cases" | "runs" };
"VS.SolutionTest.TestExpanded": { testId: string };
"VS.SolutionTest.BatchExpanded": { batchId: string };
"VS.SolutionTest.RunDetailsOpened": { runId: string };
"VS.SolutionTest.ResultInspected": { resultId: string };
"VS.SolutionTest.RawOutputViewed": { processName: string };
}

export type SolutionTestEventName = keyof SolutionTestEventMap;

type UnionToIntersection<U> = (
U extends unknown
? (arg: U) => void
: never
) extends (arg: infer I) => void
? I
: never;

/**
* Typed telemetry callback the host injects via config. An intersection of
* per-event signatures (not one generic signature) so a host tracker generic
* over a wider event map assigns to it cast-free.
*/
export type TrackSolutionTestEvent = UnionToIntersection<
{
[K in SolutionTestEventName]: (
event: K,
properties: SolutionTestEventMap[K],
) => void;
}[SolutionTestEventName]
>;

/** Per-vertical presentation config; everything else is hard-coded in `constants`. */
export interface SolutionTestsConfig {
/** Columns inserted between the Test Name and Version columns. */
Expand All @@ -19,6 +72,8 @@ export interface SolutionTestsConfig {
evaluatorRenderers?: EvaluatorRenderers;
/** Keyed by stable agent id and/or process name; unmatched outputs render as raw JSON. */
outputRenderers?: ProcessOutputRenderers;
/** Emit a telemetry event for each action. No-op unless the host supplies it. */
track?: TrackSolutionTestEvent;
}

/** Config with defaults applied — what components read from context. */
Expand All @@ -30,6 +85,8 @@ export interface ResolvedSolutionTestsConfig {
showDebug: boolean;
evaluatorRenderers: EvaluatorRenderers;
outputRenderers: ProcessOutputRenderers;
/** Optional — call sites use `track?.(…)`, so no tracking happens if absent. */
track?: TrackSolutionTestEvent;
}

export function resolveConfig(
Expand All @@ -43,5 +100,6 @@ export function resolveConfig(
showDebug: config.showDebug ?? false,
evaluatorRenderers: config.evaluatorRenderers ?? {},
outputRenderers: config.outputRenderers ?? {},
track: config.track,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { SolutionTest, SolutionTestJob } from "./types";
import { useSolutionTestsConfig } from "./context";
import { useBaselineJobs, useJobExpectedOutput } from "./hooks";
import { ExpandedAgentsView } from "./expanded-agents-view";

Expand All @@ -17,6 +18,7 @@ interface ExpandedAgentsProps {
export const ExpandedAgents = ({ test }: ExpandedAgentsProps) => {
const { jobs: baselines, isLoading } = useBaselineJobs(test.Id);
const expectedOutput = useJobExpectedOutput();
const { track } = useSolutionTestsConfig();

const [openJob, setOpenJob] = useState<SolutionTestJob | null>(null);

Expand All @@ -42,7 +44,12 @@ export const ExpandedAgents = ({ test }: ExpandedAgentsProps) => {
isLoading={isLoading}
noOutputJobIds={noOutputJobIds}
viewing={viewing}
onViewExpected={(job) => setOpenJob(job)}
onViewExpected={(job) => {
track?.("VS.SolutionTest.RawOutputViewed", {
processName: job.ProcessName,
});
setOpenJob(job);
}}
onCloseViewer={() => setOpenJob(null)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { SolutionTest, SolutionTestRun } from "./types";
import { useSolutionTestsConfig } from "./context";
import { useForceStopRun } from "./hooks";
import { ExpandedRunTestsView } from "./expanded-run-tests-view";

Expand All @@ -16,6 +17,7 @@ interface ExpandedRunTestsProps {
export const ExpandedRunTests = ({ runs, tests }: ExpandedRunTestsProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { track } = useSolutionTestsConfig();
const forceStopRun = useForceStopRun();

const stoppingRunId = forceStopRun.isPending
Expand All @@ -27,15 +29,16 @@ export const ExpandedRunTests = ({ runs, tests }: ExpandedRunTestsProps) => {
runs={runs}
tests={tests}
stoppingRunId={stoppingRunId}
onOpenDetails={(run) =>
onOpenDetails={(run) => {
track?.("VS.SolutionTest.RunDetailsOpened", { runId: run.Id });
void navigate({
to: ".",
search: (prev: Record<string, unknown>) => ({
...prev,
run: run.Id,
}),
})
}
});
}}
onForceStop={(runId) =>
forceStopRun.mutate(runId, {
onSuccess: () => toast.success(t("force_stop_initiated")),
Expand Down
3 changes: 3 additions & 0 deletions apps/apollo-vertex/registry/solution-tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export {
export type {
SolutionTestsConfig,
ResolvedSolutionTestsConfig,
SolutionTestEventMap,
SolutionTestEventName,
TrackSolutionTestEvent,
} from "./config";
export {
makeRenderer,
Expand Down
7 changes: 5 additions & 2 deletions apps/apollo-vertex/registry/solution-tests/run-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ interface RunDetailsProps {
* fetch + baseline write actions, driving the full-page run-details view. */
export const RunDetails = ({ run, subjectId, onBack }: RunDetailsProps) => {
const { t } = useTranslation();
const { showDebug } = useSolutionTestsConfig();
const { showDebug, track } = useSolutionTestsConfig();
const { results, isLoading } = useRunResults(run.Id);
const { jobs: baselines } = useBaselineJobs(run.SolutionTestId);

Expand Down Expand Up @@ -166,7 +166,10 @@ export const RunDetails = ({ run, subjectId, onBack }: RunDetailsProps) => {
updatingResultId={updatingResultId}
removingBaselineId={removingBaselineId}
onBack={onBack}
onSelect={setSelectedResultId}
onSelect={(id) => {
track?.("VS.SolutionTest.ResultInspected", { resultId: id });
setSelectedResultId(id);
}}
onAdopt={(id) =>
adopt.mutate(id, {
onSuccess: () => toast.success(t("agent_adopted_successfully")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export const SolutionTestsView = ({
const activeTab = activeTabProp ?? internalTab;
const handleTabChange = (tab: TabValue) => {
// When controlled, `activeTab` resolves to the prop so this is ignored.
config.track?.("VS.SolutionTest.TabViewed", { tab });
setInternalTab(tab);
onTabChange?.(tab);
};
Expand Down Expand Up @@ -215,6 +216,10 @@ export const SolutionTestsView = ({
aria-label={row.getIsExpanded() ? t("collapse") : t("expand")}
onClick={(e) => {
e.stopPropagation();
if (!row.getIsExpanded())
config.track?.("VS.SolutionTest.TestExpanded", {
testId: row.original.Id,
});
row.toggleExpanded();
}}
>
Expand Down Expand Up @@ -379,6 +384,10 @@ export const SolutionTestsView = ({
aria-label={row.getIsExpanded() ? t("collapse") : t("expand")}
onClick={(e) => {
e.stopPropagation();
if (!row.getIsExpanded())
config.track?.("VS.SolutionTest.BatchExpanded", {
batchId: row.original.Id,
});
row.toggleExpanded();
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { useMutation } from "@tanstack/react-query";
import { useSolution } from "@uipath/vs-core";
import { fetchAttachment } from "./attachments";
import { ENTITY } from "./constants";
import { useSolutionTestsActions, useSolutionTestsContext } from "./context";
import {
useSolutionTestsActions,
useSolutionTestsConfig,
useSolutionTestsContext,
} from "./context";
import type { AttachmentFetcher, MutationHook } from "./mutations";
import { JobRole } from "./types";
import type { SolutionTestJob } from "./types";
Expand All @@ -39,12 +43,15 @@ export function useBaselineJobs(testId: string): UseBaselineJobsResult {
/** Remove a job from the test's expected (baseline) results. */
export function useRemoveJobBaseline(): MutationHook<string> {
const actions = useSolutionTestsActions();
const { track } = useSolutionTestsConfig();
const jobsCollection = useSolutionTestCollection(ENTITY.jobs);
return useMutation({
mutationFn: async (baselineId: string) => {
await actions.removeJobBaseline(baselineId);
await jobsCollection.utils.refetch();
},
onMutate: (baselineId) =>
track?.("VS.SolutionTest.BaselineRemoved", { baselineId }),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@
*/

import { useMutation } from "@tanstack/react-query";
import { useSolutionTestsActions } from "./context";
import { useSolutionTestsActions, useSolutionTestsConfig } from "./context";
import type { MutationHook } from "./mutations";

/** Force-stop a whole batch run. */
export function useForceStopBatch(): MutationHook<string> {
const actions = useSolutionTestsActions();
const { track } = useSolutionTestsConfig();
return useMutation({
mutationFn: (batchId: string) => actions.forceStopBatch(batchId),
onMutate: (batchId) =>
track?.("VS.SolutionTest.BatchForceStopped", { batchId }),
});
}

/** Force-stop a single run. */
export function useForceStopRun(): MutationHook<string> {
const actions = useSolutionTestsActions();
const { track } = useSolutionTestsConfig();
return useMutation({
mutationFn: (runId: string) => actions.forceStopRun(runId),
onMutate: (runId) => track?.("VS.SolutionTest.RunForceStopped", { runId }),
});
}
11 changes: 10 additions & 1 deletion apps/apollo-vertex/registry/solution-tests/use-run-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { useMutation } from "@tanstack/react-query";
import { useSolution } from "@uipath/vs-core";
import { fetchAttachment } from "./attachments";
import { ENTITY } from "./constants";
import { useSolutionTestsActions, useSolutionTestsContext } from "./context";
import {
useSolutionTestsActions,
useSolutionTestsConfig,
useSolutionTestsContext,
} from "./context";
import type { AttachmentFetcher, MutationHook } from "./mutations";
import { JobRole } from "./types";
import type {
Expand Down Expand Up @@ -70,16 +74,21 @@ export function useRunResults(runId: string): UseRunResultsResult {
/** Adopt a run result's job as the test baseline. */
export function useAdoptJob(): MutationHook<string> {
const actions = useSolutionTestsActions();
const { track } = useSolutionTestsConfig();
return useMutation({
mutationFn: (resultId: string) => actions.adoptJob(resultId),
onMutate: (resultId) => track?.("VS.SolutionTest.JobAdopted", { resultId }),
});
}

/** Update the test baseline from a run result. */
export function useUpdateBaseline(): MutationHook<string> {
const actions = useSolutionTestsActions();
const { track } = useSolutionTestsConfig();
return useMutation({
mutationFn: (resultId: string) => actions.updateBaseline(resultId),
onMutate: (resultId) =>
track?.("VS.SolutionTest.BaselineUpdated", { resultId }),
});
}

Expand Down
Loading
Loading