diff --git a/__tests__/e2e/flows.e2e.test.tsx b/__tests__/e2e/flows.e2e.test.tsx new file mode 100644 index 0000000..3d7ead9 --- /dev/null +++ b/__tests__/e2e/flows.e2e.test.tsx @@ -0,0 +1,127 @@ +/** + * E2E test — Flows (detail / run / run history) + * + * Renders the real Flow detail screen under the native preset and exercises the + * full journey: metadata + diagram render, run history empty state, and the + * Run button opening the input-collection dialog and triggering with params. + */ +import React from "react"; +import { render, fireEvent, waitFor } from "@testing-library/react-native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { ToastProvider } from "~/components/ui/Toast"; +import { ConfirmProvider } from "~/components/ui/ConfirmDialog"; + +const SAFE_AREA_METRICS = { + frame: { x: 0, y: 0, width: 390, height: 844 }, + insets: { top: 47, left: 0, right: 0, bottom: 34 }, +}; + +/* ---- Mocks ---- */ + +const mockPush = jest.fn(); +jest.mock("expo-router", () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }), + useLocalSearchParams: () => ({ name: "lead_conversion" }), + useSegments: () => [], + Stack: { Screen: () => null }, +})); + +// Flow definition served by the metadata API (consumed by useFlows via apiFetch). +const FLOW = { + name: "lead_conversion", + label: "Lead Conversion Process", + description: "Convert qualified leads", + version: 1, + status: "draft", + type: "screen", + variables: [ + { name: "leadId", type: "text", isInput: true }, + { name: "opportunityName", type: "text", isInput: true }, + ], + nodes: [ + { id: "start", type: "start", label: "Start" }, + { id: "create", type: "create_record", label: "Create Account" }, + ], + edges: [{ id: "e1", source: "start", target: "create" }], +}; + +jest.mock("~/lib/objectstack", () => ({ + apiFetch: jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: "flow", items: [FLOW] }), + }), +})); + +const mockExecute = jest.fn().mockResolvedValue({ success: false, error: "Flow 'lead_conversion' not found" }); +const mockListRuns = jest.fn().mockResolvedValue({ runs: [], hasMore: false }); +jest.mock("@objectstack/client-react", () => ({ + useClient: () => ({ + automation: { execute: mockExecute, listRuns: mockListRuns, getRun: jest.fn() }, + }), +})); + +import FlowDetailScreen from "~/app/flows/[name]/index"; + +function renderScreen() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + + + , + ); +} + +describe("E2E: Flows", () => { + beforeEach(() => { + mockPush.mockClear(); + mockExecute.mockClear(); + mockListRuns.mockClear(); + }); + + it("renders the flow metadata, diagram, and an empty run history", async () => { + const { getAllByText, getByText } = renderScreen(); + + // Title appears (header + subtitle/metadata may repeat it). + await waitFor(() => expect(getAllByText("Lead Conversion Process").length).toBeGreaterThan(0)); + // Diagram nodes render. + expect(getByText("Create Account")).toBeTruthy(); + // Run history starts empty. + expect(getByText("No runs yet.")).toBeTruthy(); + // listRuns was queried for this flow. + expect(mockListRuns).toHaveBeenCalledWith("lead_conversion", { limit: 25 }); + }); + + it("opens the input dialog on Run and triggers with collected params", async () => { + const { getAllByText, getByText, getByPlaceholderText, queryByText } = renderScreen(); + + await waitFor(() => expect(getAllByText("Lead Conversion Process").length).toBeGreaterThan(0)); + + // Tap the header Run button (input-driven flow → dialog, not a confirm). + fireEvent.press(getAllByText("Run")[0]); + await waitFor(() => expect(getByText("Lead Id")).toBeTruthy()); + expect(getByText("Opportunity Name")).toBeTruthy(); + + fireEvent.changeText(getByPlaceholderText("Lead Id"), "lead-9"); + fireEvent.changeText(getByPlaceholderText("Opportunity Name"), "Acme Deal"); + + // Press the dialog's Run button (the last "Run" in the tree). + const runButtons = getAllByText("Run"); + fireEvent.press(runButtons[runButtons.length - 1]); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute).toHaveBeenCalledWith("lead_conversion", { + params: { leadId: "lead-9", opportunityName: "Acme Deal" }, + }); + + // The draft flow's inner failure surfaces (dialog closed; no crash). + await waitFor(() => expect(queryByText("Lead Id")).toBeNull()); + }); +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index d61913a..006ba88 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -107,7 +107,6 @@ export default function RootLayout() { -