@duraton/react 0.1.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { ControlAction, ListControlActionsOptions } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ export function useControlActions(opts: ListControlActionsOptions = {}) {
6
+ const client = useDuratonClient();
7
+ return useQuery<ControlAction[]>({
8
+ queryKey: ["control-actions", opts],
9
+ queryFn: () => client.runs.controlActions(opts),
10
+ });
11
+ }
@@ -0,0 +1,56 @@
1
+ import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
2
+ import type {
3
+ ListWebhookDeliveriesOptions,
4
+ WebhookDeliveriesPage,
5
+ WebhookDeliveryDetail,
6
+ WebhookEndpoint,
7
+ WebhookEndpointStats,
8
+ WebhookReceiver,
9
+ } from "@duraton/sdk/client";
10
+ import { useDuratonClient } from "../provider";
11
+
12
+ const POLL_MS = 5000;
13
+
14
+ export function useDeliveries(opts: ListWebhookDeliveriesOptions = {}) {
15
+ const client = useDuratonClient();
16
+ return useQuery<WebhookDeliveriesPage>({
17
+ queryKey: ["deliveries", opts],
18
+ queryFn: () => client.webhooks.deliveries.list(opts),
19
+ refetchInterval: POLL_MS,
20
+ placeholderData: keepPreviousData,
21
+ });
22
+ }
23
+
24
+ export function useDelivery(id: string | null) {
25
+ const client = useDuratonClient();
26
+ return useQuery<WebhookDeliveryDetail>({
27
+ queryKey: ["delivery", id],
28
+ queryFn: id != null ? () => client.webhooks.deliveries.get(id) : skipToken,
29
+ refetchInterval: POLL_MS,
30
+ });
31
+ }
32
+
33
+ export function useEndpoints() {
34
+ const client = useDuratonClient();
35
+ return useQuery<WebhookEndpoint[]>({
36
+ queryKey: ["endpoints"],
37
+ queryFn: () => client.webhooks.endpoints.list(),
38
+ });
39
+ }
40
+
41
+ export function useReceivers() {
42
+ const client = useDuratonClient();
43
+ return useQuery<WebhookReceiver[]>({
44
+ queryKey: ["receivers"],
45
+ queryFn: () => client.webhooks.receivers.list(),
46
+ });
47
+ }
48
+
49
+ export function useEndpointStats(since?: string) {
50
+ const client = useDuratonClient();
51
+ return useQuery<WebhookEndpointStats[]>({
52
+ queryKey: ["endpoint-stats", since ?? null],
53
+ queryFn: () => client.webhooks.endpoints.stats(since),
54
+ refetchInterval: POLL_MS,
55
+ });
56
+ }
@@ -0,0 +1,54 @@
1
+ import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect } from "react";
3
+ import type { DuratonClient, EventLogRecord, SendEventInput } from "@duraton/sdk/client";
4
+ import { useDuratonClient } from "../provider";
5
+ import { abortableDelay, RECONNECT_MS } from "../lib/stream";
6
+
7
+ // MAX_EVENTS caps the in-memory list so a long-open tab on the live stream does
8
+ // not grow without bound; older events stay queryable via events.list().
9
+ const MAX_EVENTS = 200;
10
+
11
+ export function useEvents() {
12
+ const client = useDuratonClient();
13
+ const qc = useQueryClient();
14
+ const query = useQuery<EventLogRecord[]>({
15
+ queryKey: ["events"],
16
+ queryFn: () => client.events.list(),
17
+ });
18
+
19
+ useEffect(() => {
20
+ const ctrl = new AbortController();
21
+ void tailEvents(client, qc, ctrl.signal);
22
+ return () => ctrl.abort();
23
+ }, [client, qc]);
24
+
25
+ return query;
26
+ }
27
+
28
+ async function tailEvents(client: DuratonClient, qc: QueryClient, signal: AbortSignal) {
29
+ while (!signal.aborted) {
30
+ try {
31
+ for await (const ev of client.events.stream({ signal })) {
32
+ qc.setQueryData<EventLogRecord[]>(["events"], (prev = []) =>
33
+ prev.some((e) => e.id === ev.id) ? prev : [ev, ...prev].slice(0, MAX_EVENTS),
34
+ );
35
+ }
36
+ } catch {
37
+ if (signal.aborted) return;
38
+ }
39
+ if (signal.aborted) return;
40
+ await abortableDelay(RECONNECT_MS, signal);
41
+ }
42
+ }
43
+
44
+ export function useTriggerEvent() {
45
+ const client = useDuratonClient();
46
+ const qc = useQueryClient();
47
+ return useMutation({
48
+ mutationFn: (input: SendEventInput) => client.events.send(input),
49
+ onSuccess: () => {
50
+ void qc.invalidateQueries({ queryKey: ["events"] });
51
+ void qc.invalidateQueries({ queryKey: ["runs"] });
52
+ },
53
+ });
54
+ }
@@ -0,0 +1,17 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import type { FlowState, FlowStateOptions } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ // Buffer counts move on their own (a debounce fires, a batch flushes) with no run
6
+ // event to ride, so this view polls on a short cadence of its own.
7
+ const POLL_MS = 5000;
8
+
9
+ export function useFlowState(opts: FlowStateOptions = {}) {
10
+ const client = useDuratonClient();
11
+ return useQuery<FlowState>({
12
+ queryKey: ["flow-state", opts],
13
+ queryFn: () => client.flowState(opts),
14
+ refetchInterval: POLL_MS,
15
+ placeholderData: keepPreviousData,
16
+ });
17
+ }
@@ -0,0 +1,68 @@
1
+ import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
2
+ import type { BulkReplayFilter, BulkReplayResult, Run } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ function invalidateRuns(qc: QueryClient) {
6
+ void qc.invalidateQueries({ queryKey: ["runs"] });
7
+ void qc.invalidateQueries({ queryKey: ["control-actions"] });
8
+ }
9
+
10
+ // In-place control (cancel/pause/resume) returns the updated run. Writing it into the
11
+ // cache lets useRun's status-driven polling self-correct without a refetch round-trip.
12
+ function useRunStatusMutation(mutationFn: (id: string) => Promise<Run>) {
13
+ const qc = useQueryClient();
14
+ return useMutation({
15
+ mutationFn,
16
+ onSuccess: (run) => {
17
+ qc.setQueryData(["run", run.id], run);
18
+ void qc.invalidateQueries({ queryKey: ["run-steps", run.id] });
19
+ invalidateRuns(qc);
20
+ },
21
+ });
22
+ }
23
+
24
+ export function useCancelRun() {
25
+ const client = useDuratonClient();
26
+ return useRunStatusMutation((id) => client.runs.cancel(id));
27
+ }
28
+
29
+ export function usePauseRun() {
30
+ const client = useDuratonClient();
31
+ return useRunStatusMutation((id) => client.runs.pause(id));
32
+ }
33
+
34
+ export function useResumeRun() {
35
+ const client = useDuratonClient();
36
+ return useRunStatusMutation((id) => client.runs.resume(id));
37
+ }
38
+
39
+ function useForkRun<TArgs>(mutationFn: (args: TArgs) => Promise<Run>) {
40
+ const qc = useQueryClient();
41
+ return useMutation<Run, Error, TArgs>({
42
+ mutationFn,
43
+ onSuccess: () => invalidateRuns(qc),
44
+ });
45
+ }
46
+
47
+ export function useReplayRun() {
48
+ const client = useDuratonClient();
49
+ return useForkRun(({ runId, input }: { runId: string; input?: unknown }) =>
50
+ client.runs.replay(runId, input),
51
+ );
52
+ }
53
+
54
+ export function useRetryFromStep() {
55
+ const client = useDuratonClient();
56
+ return useForkRun(({ runId, step }: { runId: string; step: string }) =>
57
+ client.runs.retryFromStep(runId, step),
58
+ );
59
+ }
60
+
61
+ export function useBulkReplay() {
62
+ const client = useDuratonClient();
63
+ const qc = useQueryClient();
64
+ return useMutation<BulkReplayResult, Error, BulkReplayFilter>({
65
+ mutationFn: (filter) => client.runs.bulkReplay(filter),
66
+ onSuccess: () => invalidateRuns(qc),
67
+ });
68
+ }
@@ -0,0 +1,14 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { RunStats, RunStatsOptions } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ const FALLBACK_POLL_MS = 15000;
6
+
7
+ export function useRunStats(options: RunStatsOptions = {}, opts?: { poll?: boolean }) {
8
+ const client = useDuratonClient();
9
+ return useQuery<RunStats>({
10
+ queryKey: ["run-stats", options],
11
+ queryFn: () => client.runs.stats(options),
12
+ refetchInterval: opts?.poll === false ? false : FALLBACK_POLL_MS,
13
+ });
14
+ }
@@ -0,0 +1,74 @@
1
+ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect, useState } from "react";
3
+ import type { DuratonClient, TimelineFrame } from "@duraton/sdk/client";
4
+ import { useDuratonClient } from "../provider";
5
+ import { abortableDelay, RECONNECT_MS } from "../lib/stream";
6
+ import { TERMINAL_STATUSES } from "../run-status";
7
+
8
+ // Coalesce a burst of status frames into one run/steps refetch.
9
+ const REFETCH_MS = 250;
10
+
11
+ // Tails a run's SSE timeline; status frames trigger a coalesced refetch while the
12
+ // returned frame buffer drives the live timeline panel. Keyed by runId so a run
13
+ // switch remounts with fresh state.
14
+ export function useRunStream(runId: string): TimelineFrame[] {
15
+ const client = useDuratonClient();
16
+ const qc = useQueryClient();
17
+ const [frames, setFrames] = useState<TimelineFrame[]>([]);
18
+
19
+ useEffect(() => {
20
+ const ctrl = new AbortController();
21
+ void tailRun(client, qc, runId, ctrl.signal, setFrames);
22
+ return () => ctrl.abort();
23
+ }, [client, qc, runId]);
24
+
25
+ return frames;
26
+ }
27
+
28
+ type SetFrames = (update: (prev: TimelineFrame[]) => TimelineFrame[]) => void;
29
+
30
+ async function tailRun(
31
+ client: DuratonClient,
32
+ qc: QueryClient,
33
+ runId: string,
34
+ signal: AbortSignal,
35
+ setFrames: SetFrames,
36
+ ) {
37
+ let from = 0;
38
+ let refetchTimer: ReturnType<typeof setTimeout> | undefined;
39
+ signal.addEventListener("abort", () => clearTimeout(refetchTimer), { once: true });
40
+
41
+ const refetch = () => {
42
+ void qc.invalidateQueries({ queryKey: ["run", runId] });
43
+ void qc.invalidateQueries({ queryKey: ["run-steps", runId] });
44
+ };
45
+ const scheduleRefetch = () => {
46
+ if (refetchTimer) return;
47
+ refetchTimer = setTimeout(() => {
48
+ refetchTimer = undefined;
49
+ refetch();
50
+ }, REFETCH_MS);
51
+ };
52
+
53
+ while (!signal.aborted) {
54
+ let terminal = false;
55
+ try {
56
+ for await (const frame of client.runs.watch(runId, { from, signal })) {
57
+ from = frame.seq;
58
+ setFrames((prev) => (prev.some((f) => f.seq === frame.seq) ? prev : [...prev, frame]));
59
+ if (frame.kind === "log") continue;
60
+ scheduleRefetch();
61
+ if (frame.kind === "run_status" && TERMINAL_STATUSES.has(frame.status)) terminal = true;
62
+ }
63
+ } catch {
64
+ if (signal.aborted) return;
65
+ }
66
+ if (terminal) {
67
+ clearTimeout(refetchTimer);
68
+ refetch();
69
+ return;
70
+ }
71
+ if (signal.aborted) return;
72
+ await abortableDelay(RECONNECT_MS, signal);
73
+ }
74
+ }
@@ -0,0 +1,15 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { RunSeries, RunTimeSeriesOptions } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ // The namespace stream (useRunsStream) drives refetches; this slow interval is only a safety net.
6
+ const FALLBACK_POLL_MS = 15000;
7
+
8
+ export function useRunTimeSeries(opts: RunTimeSeriesOptions = {}) {
9
+ const client = useDuratonClient();
10
+ return useQuery<RunSeries>({
11
+ queryKey: ["run-timeseries", opts],
12
+ queryFn: () => client.runs.timeseries(opts),
13
+ refetchInterval: FALLBACK_POLL_MS,
14
+ });
15
+ }
@@ -0,0 +1,27 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { Run, Step } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+ import { ACTIVE_STATUSES } from "../run-status";
5
+
6
+ const FALLBACK_POLL_MS = 10000;
7
+
8
+ export function useRun(runId: string) {
9
+ const client = useDuratonClient();
10
+ return useQuery<Run>({
11
+ queryKey: ["run", runId],
12
+ queryFn: () => client.runs.get(runId),
13
+ refetchInterval: (query) => {
14
+ const status = query.state.data?.status;
15
+ return status && ACTIVE_STATUSES.has(status) ? FALLBACK_POLL_MS : false;
16
+ },
17
+ });
18
+ }
19
+
20
+ export function useRunSteps(runId: string, active: boolean) {
21
+ const client = useDuratonClient();
22
+ return useQuery<Step[]>({
23
+ queryKey: ["run-steps", runId],
24
+ queryFn: () => client.runs.steps(runId),
25
+ refetchInterval: active ? FALLBACK_POLL_MS : false,
26
+ });
27
+ }
@@ -0,0 +1,15 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import type { Runner } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ const POLL_MS = 15000;
6
+
7
+ export function useRunners() {
8
+ const client = useDuratonClient();
9
+ return useQuery<Runner[]>({
10
+ queryKey: ["runners"],
11
+ queryFn: () => client.runners.list(),
12
+ refetchInterval: POLL_MS,
13
+ placeholderData: keepPreviousData,
14
+ });
15
+ }
@@ -0,0 +1,45 @@
1
+ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect } from "react";
3
+ import type { DuratonClient } from "@duraton/sdk/client";
4
+ import { useDuratonClient } from "../provider";
5
+ import { abortableDelay, RECONNECT_MS } from "../lib/stream";
6
+
7
+ // Coalesce a burst of namespace activity into one list/stats refetch.
8
+ const REFETCH_MS = 300;
9
+
10
+ // Tails the namespace-wide run-status stream and refetches the runs list + stats
11
+ // on any activity. Best-effort: a dropped frame self-heals on the next change.
12
+ export function useRunsStream() {
13
+ const client = useDuratonClient();
14
+ const qc = useQueryClient();
15
+ useEffect(() => {
16
+ const ctrl = new AbortController();
17
+ void tailNamespace(client, qc, ctrl.signal);
18
+ return () => ctrl.abort();
19
+ }, [client, qc]);
20
+ }
21
+
22
+ async function tailNamespace(client: DuratonClient, qc: QueryClient, signal: AbortSignal) {
23
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
+ const scheduleRefetch = () => {
25
+ if (timer) return;
26
+ timer = setTimeout(() => {
27
+ timer = undefined;
28
+ void qc.invalidateQueries({ queryKey: ["runs"] });
29
+ void qc.invalidateQueries({ queryKey: ["run-stats"] });
30
+ }, REFETCH_MS);
31
+ };
32
+ signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
33
+
34
+ while (!signal.aborted) {
35
+ try {
36
+ for await (const frame of client.runs.watchAll({ signal })) {
37
+ if (frame.kind === "run_status") scheduleRefetch();
38
+ }
39
+ } catch {
40
+ if (signal.aborted) return;
41
+ }
42
+ if (signal.aborted) return;
43
+ await abortableDelay(RECONNECT_MS, signal);
44
+ }
45
+ }
@@ -0,0 +1,17 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import type { ListRunsOptions, RunsPage } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ // The namespace stream (useRunsStream) drives refetches; this slow interval is only a
6
+ // safety net so the list still self-heals if the stream drops.
7
+ const FALLBACK_POLL_MS = 15000;
8
+
9
+ export function useRuns(options: ListRunsOptions = {}) {
10
+ const client = useDuratonClient();
11
+ return useQuery<RunsPage>({
12
+ queryKey: ["runs", options],
13
+ queryFn: () => client.runs.list(options),
14
+ refetchInterval: FALLBACK_POLL_MS,
15
+ placeholderData: keepPreviousData,
16
+ });
17
+ }
@@ -0,0 +1,92 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ EndpointInput,
4
+ EndpointPatch,
5
+ ReceiverInput,
6
+ ReceiverPatch,
7
+ } from "@duraton/sdk/client";
8
+ import { useDuratonClient } from "../provider";
9
+
10
+ // Pure mutation hooks: cache invalidation only. Consumers add toast/navigation via
11
+ // mutate(vars, { onSuccess, onError }).
12
+
13
+ function useEndpointInvalidation() {
14
+ const qc = useQueryClient();
15
+ return () => {
16
+ void qc.invalidateQueries({ queryKey: ["endpoints"] });
17
+ void qc.invalidateQueries({ queryKey: ["endpoint-stats"] });
18
+ };
19
+ }
20
+
21
+ export function useCreateEndpoint() {
22
+ const client = useDuratonClient();
23
+ const invalidate = useEndpointInvalidation();
24
+ return useMutation({
25
+ mutationFn: (input: EndpointInput) => client.webhooks.endpoints.create(input),
26
+ onSuccess: invalidate,
27
+ });
28
+ }
29
+
30
+ export function useUpdateEndpoint() {
31
+ const client = useDuratonClient();
32
+ const invalidate = useEndpointInvalidation();
33
+ return useMutation({
34
+ mutationFn: ({ id, patch }: { id: string; patch: EndpointPatch }) =>
35
+ client.webhooks.endpoints.update(id, patch),
36
+ onSuccess: invalidate,
37
+ });
38
+ }
39
+
40
+ export function useDeleteEndpoint() {
41
+ const client = useDuratonClient();
42
+ const invalidate = useEndpointInvalidation();
43
+ return useMutation({
44
+ mutationFn: (id: string) => client.webhooks.endpoints.delete(id),
45
+ onSuccess: invalidate,
46
+ });
47
+ }
48
+
49
+ function useReceiverInvalidation() {
50
+ const qc = useQueryClient();
51
+ return () => void qc.invalidateQueries({ queryKey: ["receivers"] });
52
+ }
53
+
54
+ export function useCreateReceiver() {
55
+ const client = useDuratonClient();
56
+ const invalidate = useReceiverInvalidation();
57
+ return useMutation({
58
+ mutationFn: (input: ReceiverInput) => client.webhooks.receivers.create(input),
59
+ onSuccess: invalidate,
60
+ });
61
+ }
62
+
63
+ export function useUpdateReceiver() {
64
+ const client = useDuratonClient();
65
+ const invalidate = useReceiverInvalidation();
66
+ return useMutation({
67
+ mutationFn: ({ id, patch }: { id: string; patch: ReceiverPatch }) =>
68
+ client.webhooks.receivers.update(id, patch),
69
+ onSuccess: invalidate,
70
+ });
71
+ }
72
+
73
+ export function useDeleteReceiver() {
74
+ const client = useDuratonClient();
75
+ const invalidate = useReceiverInvalidation();
76
+ return useMutation({
77
+ mutationFn: (id: string) => client.webhooks.receivers.delete(id),
78
+ onSuccess: invalidate,
79
+ });
80
+ }
81
+
82
+ export function useRedeliverDelivery() {
83
+ const client = useDuratonClient();
84
+ const qc = useQueryClient();
85
+ return useMutation({
86
+ mutationFn: (id: string) => client.webhooks.deliveries.redeliver(id),
87
+ onSuccess: (_void, id) => {
88
+ void qc.invalidateQueries({ queryKey: ["deliveries"] });
89
+ void qc.invalidateQueries({ queryKey: ["delivery", id] });
90
+ },
91
+ });
92
+ }
@@ -0,0 +1,14 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { WorkflowDef } from "@duraton/sdk/client";
3
+ import { useDuratonClient } from "../provider";
4
+
5
+ const POLL_MS = 5000;
6
+
7
+ export function useWorkflows() {
8
+ const client = useDuratonClient();
9
+ return useQuery<WorkflowDef[]>({
10
+ queryKey: ["workflows"],
11
+ queryFn: () => client.workflows.list(),
12
+ refetchInterval: POLL_MS,
13
+ });
14
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { RunStatus, TimelineFrame } from "@duraton/sdk/client";
4
+ import { useDuratonClient } from "./provider";
5
+ import { useRuns } from "./hooks/use-runs";
6
+ import { useRunStream } from "./hooks/use-run-stream";
7
+ import { useWorkflows } from "./hooks/use-workflows";
8
+ import { fakeClient, makeRun, wrapper } from "./test-utils";
9
+
10
+ function runStatus(seq: number, status: RunStatus): TimelineFrame {
11
+ return { kind: "run_status", seq, ts: "t", runId: "run_1", status };
12
+ }
13
+
14
+ function logLine(seq: number): TimelineFrame {
15
+ return {
16
+ kind: "log",
17
+ seq,
18
+ ts: "t",
19
+ runId: "run_1",
20
+ level: "info",
21
+ message: "x",
22
+ scope: "@root",
23
+ attempt: 1,
24
+ };
25
+ }
26
+
27
+ describe("useDuratonClient", () => {
28
+ it("throws when used outside a DuratonProvider", () => {
29
+ expect(() => renderHook(() => useDuratonClient())).toThrow(/DuratonProvider/);
30
+ });
31
+
32
+ it("returns the injected client inside the provider", () => {
33
+ const client = fakeClient();
34
+ const { result } = renderHook(() => useDuratonClient(), { wrapper: wrapper(client) });
35
+ expect(result.current).toBe(client);
36
+ });
37
+ });
38
+
39
+ describe("useRuns", () => {
40
+ it("returns the run list from the client", async () => {
41
+ const client = fakeClient({ runs: [makeRun({ id: "r1" }), makeRun({ id: "r2" })] });
42
+ const { result } = renderHook(() => useRuns(), { wrapper: wrapper(client) });
43
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
44
+ expect(result.current.data?.runs.map((r) => r.id)).toEqual(["r1", "r2"]);
45
+ });
46
+ });
47
+
48
+ describe("useWorkflows", () => {
49
+ it("returns the workflow list from the client", async () => {
50
+ const client = fakeClient({
51
+ workflows: [
52
+ {
53
+ name: "wf",
54
+ app: "default",
55
+ maxAttempts: 3,
56
+ backoff: "1s",
57
+ scheduled: false,
58
+ registeredAt: "t",
59
+ updatedAt: "t",
60
+ },
61
+ ],
62
+ });
63
+ const { result } = renderHook(() => useWorkflows(), { wrapper: wrapper(client) });
64
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
65
+ expect(result.current.data?.[0]?.name).toBe("wf");
66
+ });
67
+ });
68
+
69
+ describe("useRunStream", () => {
70
+ it("collects timeline frames and stops on a terminal status", async () => {
71
+ const client = fakeClient({
72
+ frames: [runStatus(1, "running"), logLine(2), runStatus(3, "succeeded")],
73
+ });
74
+ const { result } = renderHook(() => useRunStream("run_1"), { wrapper: wrapper(client) });
75
+ await waitFor(() => expect(result.current.length).toBe(3));
76
+ expect(result.current.map((f) => f.seq)).toEqual([1, 2, 3]);
77
+ });
78
+
79
+ it("dedupes frames that repeat a seq", async () => {
80
+ const client = fakeClient({
81
+ frames: [runStatus(1, "running"), runStatus(1, "running"), runStatus(2, "succeeded")],
82
+ });
83
+ const { result } = renderHook(() => useRunStream("run_1"), { wrapper: wrapper(client) });
84
+ await waitFor(() => expect(result.current.length).toBe(2));
85
+ expect(result.current.map((f) => f.seq)).toEqual([1, 2]);
86
+ });
87
+ });