diff --git a/captain/models/test_sequencer.py b/captain/models/test_sequencer.py index b48eab714..0c02e7408 100644 --- a/captain/models/test_sequencer.py +++ b/captain/models/test_sequencer.py @@ -19,6 +19,7 @@ class TestTypes(StrEnum): python = "python" flojoy = "flojoy" matlab = "matlab" + placeholder = "placeholder" class StatusTypes(StrEnum): diff --git a/captain/utils/test_sequencer/run_test_sequence.py b/captain/utils/test_sequencer/run_test_sequence.py index 8d40f064b..1783f2e00 100644 --- a/captain/utils/test_sequencer/run_test_sequence.py +++ b/captain/utils/test_sequencer/run_test_sequence.py @@ -165,6 +165,27 @@ def _run_pytest(node: TestNode) -> Extract: ) +@_with_stream_test_result +def _run_placeholder(node: TestNode) -> Extract: + """ + @params file_path: path to the file + @returns: + bool: result of the test + float: time taken to execute the test + str: error message if any + """ + return ( + lambda _: None, + TestResult( + node, + False, + 0, + "Placeholder test not implemented", + utcnow_str(), + ), + ) + + def _eval_condition( result_dict: dict[str, TestResult], condition: str, identifiers: set[str] ): @@ -244,6 +265,7 @@ def get_next_children_from_context(context: Context): { TestTypes.python: (None, _run_python), TestTypes.pytest: (None, _run_pytest), + TestTypes.placeholder: (None, _run_placeholder), }, ), "conditional": ( diff --git a/playwright-test/13_create_test_sequence.spec.ts b/playwright-test/13_create_test_sequence.spec.ts index ae0471e56..13bb93178 100644 --- a/playwright-test/13_create_test_sequence.spec.ts +++ b/playwright-test/13_create_test_sequence.spec.ts @@ -67,25 +67,8 @@ test.describe("Create a test sequence", () => { await expect(window.getByTestId(Selectors.newDropdown)).toBeEnabled({ timeout: 15000, }); - await window.getByTestId(Selectors.newDropdown).click(); - await window.getByTestId(Selectors.importTestBtn).click(); - - // Select the fixture file - const customTestFile = join(__dirname, "fixtures/custom-sequences/test.py"); - await app.evaluate(async ({ dialog }, customTestFile) => { - dialog.showOpenDialog = () => - Promise.resolve({ filePaths: [customTestFile], canceled: false }); - }, customTestFile); - - // Click on Pytest test to open modal - await window.getByTestId(Selectors.pytestBtn).click(); - - // Expect test to be loaded - await expect( - window.locator("div", { hasText: "test_one" }).first(), - ).toBeVisible(); - // Ctrl/meta + p key shortcut to save the sequence + // Ctrl/meta + s key shortcut to save the sequence if (process.platform === "darwin") { await window.keyboard.press("Meta+s"); } else { diff --git a/src/renderer/hooks/useTestImport.ts b/src/renderer/hooks/useTestImport.ts index d5f7cdbde..8fdeddce7 100644 --- a/src/renderer/hooks/useTestImport.ts +++ b/src/renderer/hooks/useTestImport.ts @@ -17,16 +17,16 @@ function parseDiscoverContainer( settings: ImportTestSettings, ) { return map(data.response, (container) => { - const new_elem = createNewTest( - container.testName, - container.path, - settings.importType, - ); + const new_elem = createNewTest({ + name: container.testName, + path: container.path, + type: settings.importType, + }); return new_elem; }); } -export const useTestImport = () => { +export const useDiscoverAndImportTests = () => { const { addNewElems } = useDisplayedSequenceState(); const { openErrorModal } = useSequencerModalStore(); @@ -125,3 +125,49 @@ export const useTestImport = () => { return openFilePicker; }; + +export const useDiscoverPytestElements = () => { + const handleUserDepInstall = useCallback(async (depName: string) => { + const promise = () => window.api.poetryInstallDepUserGroup(depName); + toast.promise(promise, { + loading: `Installing ${depName}...`, + success: () => { + return `${depName} has been added.`; + }, + error: + "Could not install the library. Please consult the Dependency Manager in the settings.", + }); + }, []); + + async function getTests(path: string) { + const res = await discoverPytest(path, false); + if (res.isErr()) { + return err(res.error); + } + const data = res.value; + if (data.error) { + return err(Error(data.error)); + } + for (const lib of data.missingLibraries) { + toast.error(`Missing Python Library: ${lib}`, { + action: { + label: "Install", + onClick: () => { + handleUserDepInstall(lib); + }, + }, + }); + return err(Error("Please retry after installing the missing libraries.")); + } + const newElems = parseDiscoverContainer(data, { + importAsOneRef: false, + importType: "pytest", + }); + if (newElems.length === 0) { + return err(Error("No tests were found in the specified file.")); + } + return ok(newElems); + } + + return getTests; +}; diff --git a/src/renderer/hooks/useTestSequencerState.ts b/src/renderer/hooks/useTestSequencerState.ts index 4e461f697..cc91fc329 100644 --- a/src/renderer/hooks/useTestSequencerState.ts +++ b/src/renderer/hooks/useTestSequencerState.ts @@ -17,7 +17,7 @@ import useWithPermission from "@/renderer/hooks/useWithPermission"; import { useSequencerStore } from "@/renderer/stores/sequencer"; import { useShallow } from "zustand/react/shallow"; import { v4 as uuidv4 } from "uuid"; -import { Err, Ok, Result } from "neverthrow"; +import { Err, Result, err, ok } from "neverthrow"; import { verifyElementCompatibleWithSequence } from "@/renderer/routes/test_sequencer_panel/utils/SequenceHandler"; import { toast } from "sonner"; import { SendJsonMessage } from "react-use-websocket/dist/lib/types"; @@ -28,6 +28,7 @@ import { testSequenceStopRequest, } from "../routes/test_sequencer_panel/models/models"; import { produce } from "immer"; +import { z } from "zod"; // sync this with the definition of setElems export type SetElemsFn = { @@ -98,34 +99,38 @@ const validateElements = ( return !validators.some((validator) => !validator(elems), validators); }; -export function createNewTest( - name: string, - path: string, +export const NewTest = z.object({ + name: z.string(), + path: z.string(), type: TestType, - exportToCloud?: boolean, - id?: string, - groupId?: string, - minValue?: number, - maxValue?: number, - unit?: string, -): Test { + exportToCloud: z.boolean().optional(), + id: z.string().optional(), + groupId: z.string().optional(), + minValue: z.number().optional(), + maxValue: z.number().optional(), + unit: z.string().optional(), +}); + +export type NewTest = z.infer; + +export function createNewTest(test: NewTest): Test { const newTest: Test = { type: "test", - id: id || uuidv4(), - groupId: groupId || uuidv4(), - path: path, - testName: name, + id: test.id || uuidv4(), + groupId: test.groupId || uuidv4(), + path: test.path, + testName: test.name, runInParallel: false, - testType: type, + testType: test.type, status: "pending", completionTime: undefined, error: null, isSavedToCloud: false, - exportToCloud: exportToCloud === undefined ? true : exportToCloud, + exportToCloud: test.exportToCloud === undefined ? true : test.exportToCloud, createdAt: new Date().toISOString(), - minValue: minValue, - maxValue: maxValue, - unit: unit, + minValue: test.minValue, + maxValue: test.maxValue, + unit: test.unit, }; return newTest; } @@ -172,7 +177,7 @@ export function useDisplayedSequenceState() { p: | TestSequenceElement[] | ((elems: TestSequenceElement[]) => TestSequenceElement[]), - ) { + ): Result { let candidateElems: TestSequenceElement[]; // handle overloads @@ -189,7 +194,7 @@ export function useDisplayedSequenceState() { ); if (!res) { console.error("Validation failed"); - return; + return err(new Error("Validation failed")); } // PASS @@ -198,13 +203,14 @@ export function useDisplayedSequenceState() { // creates tree to send to backend setTree(createTestSequenceTree(candidateElems)); + return ok(undefined); } const setElemsWithPermissions = withPermissionCheck(setElems); async function AddNewElems( newElems: TestSequenceElement[], - ): Promise> { + ): Promise> { // Validate with project if (project !== null) { const result = await verifyElementCompatibleWithSequence( @@ -216,8 +222,8 @@ export function useDisplayedSequenceState() { } } // Add new elements - setElems((elems) => [...elems, ...newElems]); - return new Ok(null); + const result = setElems((elems) => [...elems, ...newElems]); + return result; } const addNewElemsWithPermissions = withPermissionCheck(AddNewElems); diff --git a/src/renderer/routes/test_sequencer_panel/components/DesignBar.tsx b/src/renderer/routes/test_sequencer_panel/components/DesignBar.tsx index 249e9bb84..2c5dc7c74 100644 --- a/src/renderer/routes/test_sequencer_panel/components/DesignBar.tsx +++ b/src/renderer/routes/test_sequencer_panel/components/DesignBar.tsx @@ -28,6 +28,7 @@ import { HoverCardTrigger, } from "@/renderer/components/ui/hover-card"; import _ from "lodash"; +import { CreatePlaceholderTestModal } from "./modals/CreatePlaceholderTestModal"; import { SequencerGalleryModal } from "./modals/SequencerGalleryModal"; export function DesignBar() { @@ -64,10 +65,18 @@ export function DesignBar() { }, [elems, sequences, cycleRuns]); const [displayTotal, setDisplayTotal] = useState(false); + const [ + isCreatePlaceholderTestModalOpen, + setIsCreatePlaceholderTestModalOpen, + ] = useState(false); const [isGalleryOpen, setIsGalleryOpen] = useState(false); return (
+ New Test + { + setIsCreatePlaceholderTestModalOpen(true); + }} + data-testid="placeholder-test-button" + > + + New Placeholder + { setIsCreateProjectModalOpen(true); @@ -136,17 +157,6 @@ export function DesignBar() {
)} - {/* Comming soon - - */} {sequences.length <= 1 ? ( diff --git a/src/renderer/routes/test_sequencer_panel/components/data-table/TestTable.tsx b/src/renderer/routes/test_sequencer_panel/components/data-table/TestTable.tsx index d04b44300..1785dbeeb 100644 --- a/src/renderer/routes/test_sequencer_panel/components/data-table/TestTable.tsx +++ b/src/renderer/routes/test_sequencer_panel/components/data-table/TestTable.tsx @@ -58,6 +58,8 @@ import useWithPermission from "@/renderer/hooks/useWithPermission"; import { useSequencerModalStore } from "@/renderer/stores/modal"; import { WriteMinMaxModal } from "@/renderer/routes/test_sequencer_panel/components/modals/WriteMinMaxModal"; import { toast } from "sonner"; +import { ChangeLinkedTestModal } from "../modals/ChangeLinkedTest"; +import { ImportType } from "../modals/ImportTestModal"; import { useSequencerStore } from "@/renderer/stores/sequencer"; import { useShallow } from "zustand/react/shallow"; @@ -349,6 +351,8 @@ export function TestTable() { }, }); + // Remove ---------------------- + const handleClickRemoveTests = () => { onRemoveTest(map(Object.keys(rowSelection), (idxStr) => parseInt(idxStr))); setRowSelection([]); @@ -366,6 +370,8 @@ export function TestTable() { }); }; + // Conditional ------------------ + const addConditionalAfterIdx = useRef(-1); const [showWriteConditionalModal, setShowWriteConditionalModal] = @@ -411,6 +417,8 @@ export function TestTable() { setShowWriteConditionalModal(true); }; + // Edit MinMax ------------------ + const onClickWriteMinMax = (idx: number) => { writeMinMaxForIdx.current = idx; setShowWriteMinMaxModal(true); @@ -434,6 +442,26 @@ export function TestTable() { }); }; + // Change linked test ------------ + + const [openLinkedTestModal, setOpenLinkedTestModal] = useState(false); + const testRef = useRef(-1); + + const handleChangeLinkedTest = (newPath: string, testType: ImportType) => { + setElems((data) => { + const new_data = [...data]; + const test = new_data[testRef.current] as Test; + new_data[testRef.current] = { + ...test, + path: newPath, + testType: testType, + }; + return new_data; + }); + }; + + // Context Menu ------------------ + const getSpecificContextMenuItems = (row: Row) => { switch (row.original.type) { case "test": @@ -470,6 +498,11 @@ export function TestTable() { setModalOpen={setShowWriteMinMaxModal} handleWrite={onSubmitWriteMinMax} /> + {openPyTestFileModal && ( Add Conditional + {row.original.type === "test" && + row.original.testType !== "placeholder" && ( + <> + + { + setOpenPyTestFileModal(true); + setTestToDisplay(row.original as Test); + }} + > + Consult Code + + + )} + {row.original.type === "test" && + row.original.testType === "placeholder" && ( + <> + + + Not Linked to any code + + + )} {row.original.type === "test" && ( <> - - { - setOpenPyTestFileModal(true); - setTestToDisplay(row.original as Test); - }} - > - Consult Code - { @@ -617,6 +664,15 @@ export function TestTable() { > Edit Expected Value + { + setOpenLinkedTestModal(true); + testRef.current = parseInt(row.id); + setTestToDisplay(row.original as Test); + }} + > + Change executable + )} diff --git a/src/renderer/routes/test_sequencer_panel/components/modals/ChangeLinkedTest.tsx b/src/renderer/routes/test_sequencer_panel/components/modals/ChangeLinkedTest.tsx new file mode 100644 index 000000000..a39362c0b --- /dev/null +++ b/src/renderer/routes/test_sequencer_panel/components/modals/ChangeLinkedTest.tsx @@ -0,0 +1,149 @@ +import { Button } from "@/renderer/components/ui/button"; +import { Dialog, DialogContent } from "@/renderer/components/ui/dialog"; +import { Separator } from "@/renderer/components/ui/separator"; +import { useAppStore } from "@/renderer/stores/app"; +import { ExternalLinkIcon } from "lucide-react"; +import { useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { ImportType } from "./ImportTestModal"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/renderer/components/ui/select"; +import { useDiscoverPytestElements } from "@/renderer/hooks/useTestImport"; +import { TestSequenceElement } from "@/renderer/types/test-sequencer"; +import { toast } from "sonner"; + +export const ChangeLinkedTestModal = ({ + isModalOpen, + setModalOpen, + handleSubmit, +}: { + isModalOpen: boolean; + setModalOpen: (value: boolean) => void; + handleSubmit: (path: string, testType: ImportType) => void; +}) => { + const [availableTests, setAvailableTests] = useState( + [], + ); + const [selectedPath, setSelectedPath] = useState(""); + + const { setIsDepManagerModalOpen } = useAppStore( + useShallow((state) => ({ + setIsDepManagerModalOpen: state.setIsDepManagerModalOpen, + })), + ); + + const discoverPytestElement = useDiscoverPytestElements(); + + const handleDiscoverPytestElements = async (filePath: string) => { + const result = await discoverPytestElement(filePath); + if (result.isOk()) { + setAvailableTests(result.value); + if (result.value.length > 0) { + setSelectedPath(result.value[0].path); + } + } else { + console.error(result.error); + } + }; + + const handleFilePicker = async () => { + const res = await window.api.openTestPicker(); + if (!res) return; + if (res.filePath) { + await handleDiscoverPytestElements(res.filePath); + } + }; + + const handleSubmitByType = (testType: ImportType) => { + if (testType === "pytest") { + if (selectedPath === "") { + toast.error("Please select a test to link to"); + } + handleSubmit(selectedPath, testType); + } else { + window.api.openTestPicker().then((result) => { + if (!result) { + return; + } + const { filePath } = result; + handleSubmit(filePath, testType); + }); + } + setModalOpen(false); + }; + + return ( + + +

+ Select a test to link to +

+

Pytest

+
+ +
+ +
+
+ + +

Python Script

+ +
+
+
+ +
+
+ +
+ ); +}; diff --git a/src/renderer/routes/test_sequencer_panel/components/modals/CreatePlaceholderTestModal.tsx b/src/renderer/routes/test_sequencer_panel/components/modals/CreatePlaceholderTestModal.tsx new file mode 100644 index 000000000..b92b68137 --- /dev/null +++ b/src/renderer/routes/test_sequencer_panel/components/modals/CreatePlaceholderTestModal.tsx @@ -0,0 +1,154 @@ +import { Button } from "@/renderer/components/ui/button"; +import { Dialog, DialogContent } from "@/renderer/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/renderer/components/ui/form"; +import { Input } from "@/renderer/components/ui/input"; +import { + createNewTest, + useDisplayedSequenceState, +} from "@/renderer/hooks/useTestSequencerState"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + name: z.string().min(1).regex(/\S/), + min: z.coerce.number().optional(), + max: z.coerce.number().optional(), + unit: z.string().optional(), +}); + +export const CreatePlaceholderTestModal = ({ + isModalOpen, + setModalOpen, +}: { + isModalOpen: boolean; + setModalOpen: (value: boolean) => void; +}) => { + const { addNewElems } = useDisplayedSequenceState(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + unit: undefined, + min: undefined, + max: undefined, + }, + }); + + async function onSubmit(values: z.infer) { + const res = await addNewElems([ + createNewTest({ + name: values.name, + path: "", + type: "placeholder", + exportToCloud: false, + minValue: values.min, + maxValue: values.max, + unit: values.unit, + }), + ]); + if (res.isErr()) { + return; + } + setModalOpen(false); + } + + return ( + + +

+ Create a placeholder test step +

+

+ {" "} + This will create a new test step with the given name and expected + value without being link to any code. The code can be added later. +

+ +
+ + ( + + Test Name + + + + + + )} + /> + +
+

+ Expected Value +

+
+
+ ( + + Minimum + + + + + + )} + /> +
+ +
+ ( + + Maximun + + + + + + )} + /> +
+ +
+ ( + + Displayed Unit + + + + + + )} + /> +
+
+
+ + + +
+
+ ); +}; diff --git a/src/renderer/routes/test_sequencer_panel/components/modals/ImportTestModal.tsx b/src/renderer/routes/test_sequencer_panel/components/modals/ImportTestModal.tsx index dfea5358a..4df0c7234 100644 --- a/src/renderer/routes/test_sequencer_panel/components/modals/ImportTestModal.tsx +++ b/src/renderer/routes/test_sequencer_panel/components/modals/ImportTestModal.tsx @@ -2,7 +2,7 @@ import { Button } from "@/renderer/components/ui/button"; import { Checkbox } from "@/renderer/components/ui/checkbox"; import { Dialog, DialogContent } from "@/renderer/components/ui/dialog"; import { Separator } from "@/renderer/components/ui/separator"; -import { useTestImport } from "@/renderer/hooks/useTestImport"; +import { useDiscoverAndImportTests } from "@/renderer/hooks/useTestImport"; import { useDisplayedSequenceState } from "@/renderer/hooks/useTestSequencerState"; import { useAppStore } from "@/renderer/stores/app"; import { useSequencerModalStore } from "@/renderer/stores/modal"; @@ -28,7 +28,7 @@ export const ImportTestModal = () => { })), ); - const openFilePicker = useTestImport(); + const openFilePicker = useDiscoverAndImportTests(); const { setIsLocked } = useDisplayedSequenceState(); const handleImportTest = (importType: ImportType) => { diff --git a/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts b/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts index fe5dcfd3f..d2924237e 100644 --- a/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts +++ b/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts @@ -275,17 +275,17 @@ async function createExportableSequenceElementsFromTestSequencerElements( } const elements = [...elems].map((elem) => { return elem.type === "test" - ? createNewTest( - removeBaseFolderFromName(elem.testName, baseFolder), - elem.path.replaceAll(baseFolder, ""), - elem.testType, - elem.exportToCloud, - elem.id, - elem.groupId, - elem.minValue, - elem.maxValue, - elem.unit, - ) + ? createNewTest({ + name: removeBaseFolderFromName(elem.testName, baseFolder), + path: elem.path.replaceAll(baseFolder, ""), + type: elem.testType, + exportToCloud: elem.exportToCloud, + id: elem.id, + groupId: elem.groupId, + minValue: elem.minValue, + maxValue: elem.maxValue, + unit: elem.unit, + }) : { ...elem, condition: elem.condition.replaceAll(baseFolder, ""), @@ -301,17 +301,17 @@ async function createTestSequencerElementsFromSequenceElements( ): Promise> { const elements: TestSequenceElement[] = [...sequence.elems].map((elem) => { return elem.type === "test" - ? createNewTest( - removeBaseFolderFromName(elem.testName, baseFolder), - baseFolder + elem.path, - elem.testType, - elem.exportToCloud, - elem.id, - elem.groupId, - elem.minValue, - elem.maxValue, - elem.unit, - ) + ? createNewTest({ + name: removeBaseFolderFromName(elem.testName, baseFolder), + path: baseFolder + elem.path, + type: elem.testType, + exportToCloud: elem.exportToCloud, + id: elem.id, + groupId: elem.groupId, + minValue: elem.minValue, + maxValue: elem.maxValue, + unit: elem.unit, + }) : { ...elem, }; diff --git a/src/renderer/types/test-sequencer.ts b/src/renderer/types/test-sequencer.ts index 9041fe679..9d4b8a2a5 100644 --- a/src/renderer/types/test-sequencer.ts +++ b/src/renderer/types/test-sequencer.ts @@ -4,7 +4,13 @@ export type LockedContextType = { isLocked: boolean; }; -export const TestType = z.enum(["pytest", "python", "flojoy", "matlab"]); +export const TestType = z.enum([ + "pytest", + "python", + "flojoy", + "matlab", + "placeholder", +]); export type TestType = z.infer; export const ResultType = z.enum(["pass", "fail", "aborted"]);