diff --git a/electron-builder.yaml b/electron-builder.yaml index 590714b0b..a36db7144 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -29,6 +29,8 @@ extraResources: to: "poetry.lock" - from: "blocks" to: "blocks" + - from: "examples" + to: "examples" mac: icon: ./public/favicon.icns diff --git a/examples/test-sequencer-conditional-example/Conditional_Demo.tjoy b/examples/test-sequencer-conditional-example/Conditional_Demo.tjoy new file mode 100644 index 000000000..8f3abc995 --- /dev/null +++ b/examples/test-sequencer-conditional-example/Conditional_Demo.tjoy @@ -0,0 +1 @@ +{"name":"Conditional_Demo","description":"Example","elems":[{"type":"test","id":"ca204090-41bb-4236-97ba-623c4257ef0a","groupId":"41717403-d6ba-49c3-b451-a4a0ae815b2c","path":"test.py::test_will_pass","testName":"test_will_pass","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T15:57:40.507Z"},{"type":"conditional","id":"5ec59132-f8f5-45be-b0ad-31124a3a6ba0","role":"start","groupId":"0a78f451-a9b6-4fa9-9fe3-16dea93fa46b","conditionalType":"if","condition":" $test_will_pass"},{"type":"test","id":"67014fb0-9a93-433b-8398-df103de3dcc1","groupId":"8a8ecab5-b765-4944-a46c-63111fd59e03","path":"test.py::test_for_example_1","testName":"test_for_example_1","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T15:57:40.507Z"},{"type":"test","id":"7d6d00ee-f418-49d4-9bcd-5b9cc2e28470","groupId":"e2de5be6-ba8c-4aa8-b6ac-b32b1f30dec1","path":"test.py::test_will_fail","testName":"test_will_fail","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T15:57:40.507Z"},{"type":"conditional","id":"5ec59132-f8f5-45be-b0ad-31124a3a6ba0","role":"start","groupId":"a45f0b58-b74e-4626-8a0a-2dec3d8e9cfa","conditionalType":"if","condition":" $test_will_fail"},{"type":"test","id":"b4927c15-00e8-4ea4-8788-af395be14775","groupId":"b824ead8-84ad-4de3-88c1-21e4b46343fe","path":"test.py::test_for_example_2","testName":"test_for_example_2","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T15:57:40.507Z"},{"type":"conditional","id":"d5b1cf12-f50d-4f5a-88d4-b7a9f982a447","role":"between","groupId":"a45f0b58-b74e-4626-8a0a-2dec3d8e9cfa","conditionalType":"else","condition":""},{"type":"test","id":"5ad4da63-40fe-45a2-ab1e-235fd067bde2","groupId":"8f02f062-19f5-448f-83b4-1fcc4e9dc07a","path":"test.py::test_for_example_3","testName":"test_for_example_3","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T15:57:40.507Z"},{"type":"conditional","id":"c45616fd-cc5a-4469-9bb6-5f3a8ae74bd7","role":"end","groupId":"a45f0b58-b74e-4626-8a0a-2dec3d8e9cfa","conditionalType":"end","condition":""},{"type":"conditional","id":"d5b1cf12-f50d-4f5a-88d4-b7a9f982a447","role":"between","groupId":"0a78f451-a9b6-4fa9-9fe3-16dea93fa46b","conditionalType":"else","condition":""},{"type":"test","id":"01cafc7f-0c21-47b9-8246-305b36a0c23a","groupId":"2e320c36-4b8b-48ec-97af-732e623c3776","path":"test.py::test_for_example_4","testName":"test_for_example_4","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T15:57:40.507Z"},{"type":"conditional","id":"c45616fd-cc5a-4469-9bb6-5f3a8ae74bd7","role":"end","groupId":"0a78f451-a9b6-4fa9-9fe3-16dea93fa46b","conditionalType":"end","condition":""}],"projectPath":"C:/Users/zzzgu/Documents/flojoy/repo/studio/src/renderer/data/apps/sequencer/conditional/","interpreter":{"type":"flojoy","path":null,"requirementsPath":"flojoy_requirements.txt"}} diff --git a/examples/test-sequencer-conditional-example/flojoy_requirements.txt b/examples/test-sequencer-conditional-example/flojoy_requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/examples/test-sequencer-conditional-example/test.py b/examples/test-sequencer-conditional-example/test.py new file mode 100644 index 000000000..ac1b4a8a7 --- /dev/null +++ b/examples/test-sequencer-conditional-example/test.py @@ -0,0 +1,28 @@ +""" +Simple test file to demo how to build conditional tests +- With pytest, all test files should start with 'test_' to be recognized +""" + + +def test_will_pass(): + assert True + + +def test_will_fail(): + assert False + + +def test_for_example_1(): + assert 1 == 1 + + +def test_for_example_2(): + assert 2 == 2 + + +def test_for_example_3(): + assert 3 == 3 + + +def test_for_example_4(): + assert 4 == 4 diff --git a/examples/test-sequencer-expected-exported-example/Export_&_Expected_Demo.tjoy b/examples/test-sequencer-expected-exported-example/Export_&_Expected_Demo.tjoy new file mode 100644 index 000000000..0296bb559 --- /dev/null +++ b/examples/test-sequencer-expected-exported-example/Export_&_Expected_Demo.tjoy @@ -0,0 +1 @@ +{"name":"Export_&_Expected_Demo","description":"Consult the Test Step Code!","elems":[{"type":"test","id":"acb47b74-d1dc-4f4b-b3ef-506acb2f53e1","groupId":"f90f758a-a705-4dd0-b3b6-4ae032d22ea3","path":"test.py::test_min_max","testName":"test_min_max","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T16:13:35.911Z","minValue":5,"maxValue":10,"unit":""},{"type":"test","id":"53bb5746-c720-45c4-8121-9dc469d07927","groupId":"397475ec-c600-4478-8725-b7b8e824d599","path":"test.py::test_min","testName":"test_min","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T16:13:35.911Z","minValue":5,"unit":""},{"type":"test","id":"55b82abb-37fb-4e32-9379-3609642d01af","groupId":"0377f366-edb7-4a51-97cd-2867bd944cd2","path":"test.py::test_max","testName":"test_max","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T16:13:35.911Z","maxValue":7,"unit":""},{"type":"test","id":"029a20ad-4c3f-47a5-82aa-2d558eda418a","groupId":"97ba9e80-3133-4dfa-9806-770d47ffb1e4","path":"test.py::test_export_dataframe","testName":"test_export_dataframe","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T16:13:35.911Z"},{"type":"test","id":"2a75451e-49f6-4810-827e-2b9769d12bc4","groupId":"096274ef-132f-498a-a67a-a279c65b9a8a","path":"test.py::test_export","testName":"test_export","runInParallel":false,"testType":"pytest","status":"pending","error":null,"isSavedToCloud":false,"exportToCloud":true,"createdAt":"2024-04-20T16:13:35.911Z"}],"projectPath":"C:/Users/zzzgu/Documents/flojoy/repo/studio/src/renderer/data/apps/sequencer/expected_exported_values/","interpreter":{"type":"flojoy","path":null,"requirementsPath":"flojoy_requirements.txt"}} diff --git a/examples/test-sequencer-expected-exported-example/flojoy_requirements.txt b/examples/test-sequencer-expected-exported-example/flojoy_requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/examples/test-sequencer-expected-exported-example/test.py b/examples/test-sequencer-expected-exported-example/test.py new file mode 100644 index 000000000..1d764c5bb --- /dev/null +++ b/examples/test-sequencer-expected-exported-example/test.py @@ -0,0 +1,48 @@ +from flojoy_cloud import test_sequencer +import pandas as pd + + +def test_min_max(): + value = 6.15 + test_sequencer.export(value) + assert test_sequencer.is_in_range(value) + + +def test_min(): + value = 6.15 + # If not Max value is defined, the value will be checked against the Min value. + test_sequencer.export(value) + assert test_sequencer.is_in_range(value) + + +def test_max(): + value = 6.15 + test_sequencer.export(value) + + assert test_sequencer.is_in_range(value) + # If multiple assert statements are defined and one of them fails: + # - the rest of the assert statements will not be executed, and the result will + # be reported to the sequencer. + # - the sequencer will report the error, and the test will be marked as failed. + assert 0 < value + + +def test_export_dataframe(): + df = pd.DataFrame({"value": [6.15, 6.15, 6.15]}) + # Boolean and DataFrame values will be exported to the Cloud. + test_sequencer.export(df) + + assert df is not None + + +def test_export(): + value = 6.15 + # Always export as early as possible to avoid missing data. + test_sequencer.export(value) + assert 12 < value # <-- FAIL + + # Only the last executed export statement will be exported to the Cloud and + # reported to the sequencer. + test_sequencer.export(20) + + assert 0 < value diff --git a/package.json b/package.json index 23cdc75e1..5d8b4051e 100644 --- a/package.json +++ b/package.json @@ -171,5 +171,5 @@ ], "all": true }, - "packageManager": "pnpm@9.0.5" + "packageManager": "pnpm@9.0.6" } diff --git a/playwright-test/15_sequences_gallery.spec.ts b/playwright-test/15_sequences_gallery.spec.ts new file mode 100644 index 000000000..e1442b30b --- /dev/null +++ b/playwright-test/15_sequences_gallery.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, Page, ElectronApplication } from "@playwright/test"; +import { _electron as electron } from "playwright"; +import { + STARTUP_TIMEOUT, + getExecutablePath, + mockDialogMessage, + standbyStatus, +} from "./utils"; +import { Selectors } from "./selectors"; + +test.describe("Load a demo test sequence", () => { + let window: Page; + let app: ElectronApplication; + test.beforeAll(async () => { + test.setTimeout(STARTUP_TIMEOUT); + const executablePath = getExecutablePath(); + app = await electron.launch({ executablePath }); + await mockDialogMessage(app); + window = await app.firstWindow(); + await expect( + window.locator("code", { hasText: standbyStatus }), + ).toBeVisible({ timeout: STARTUP_TIMEOUT }); + await window.getByTestId(Selectors.closeWelcomeModalBtn).click(); + // Switch to sequencer tab + await window.getByTestId(Selectors.testSequencerTabBtn).click(); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test("Should load and run a sequence", async () => { + await expect(window.getByTestId(Selectors.newDropdown)).toBeEnabled({ + timeout: 15000, + }); + + // Open the sequence gallery + await window.getByTestId(Selectors.newDropdown).click(); + await window.getByTestId(Selectors.openSequenceGalleryBtn).click(); + + // Open a sequence + await window + .getByTestId("test_step_with_expected_and_exported_values") + .nth(1) + .click(); + + // Expect sequence and tests to be loaded + await expect( + window.locator("div", { hasText: "Export_&_Expected_Demo" }).first(), + ).toBeVisible(); + + // Expect test steps to bey loaded + await expect( + window.locator("div", { hasText: "test_min_max" }).first(), + ).toBeVisible(); + + // Run the sequence + await window.getByTestId(Selectors.runBtn).click(); + await window.waitForTimeout(10000); + + // Check the status + await expect(window.getByTestId(Selectors.globalStatusBadge)).toContainText( + "FAIL", + ); + await expect(window.getByTestId("status-test_min_max")).toContainText( + "PASS", + ); + }); +}); diff --git a/playwright-test/selectors.ts b/playwright-test/selectors.ts index ff2009122..a6edc2090 100644 --- a/playwright-test/selectors.ts +++ b/playwright-test/selectors.ts @@ -43,6 +43,7 @@ export enum Selectors { pytestBtn = "pytest-btn", newDropdown = "new-dropdown", importTestBtn = "import-test-button", + openSequenceGalleryBtn = "seq-gallery-btn", globalStatusBadge = "global-status-badge", newSeqModalNameInput = "new-seq-modal-name-input", newSeqModalDescInput = "new-seq-modal-desc-input", diff --git a/src/api/index.ts b/src/api/index.ts index a3314bb44..17c45fd13 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -148,11 +148,13 @@ export default { openAllFilesInFolder: ( folderPath: string, allowedExtensions: string[] = ["json"], + relativeToResources: boolean = false, ): Promise<{ filePath: string; fileContent: string }[] | undefined> => ipcRenderer.invoke( API.openAllFilesInFolderPicker, folderPath, allowedExtensions, + relativeToResources, ), getFileContent: (filepath: string): Promise => diff --git a/src/main/utils.ts b/src/main/utils.ts index f2b4b6185..35355d36f 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -190,7 +190,12 @@ export const openAllFilesInFolderPicker = ( _, folderPath: string, allowedExtensions: string[] = ["json"], + relativeToResources: boolean = false, ): { filePath: string; fileContent: string }[] | undefined => { + // Append the current working directory if the path is relative + if (relativeToResources) { + folderPath = join(process.resourcesPath, folderPath); + } // Return multiple files or all files with the allowed extensions if a folder is selected if (!fs.existsSync(folderPath) || !fs.lstatSync(folderPath).isDirectory()) { return undefined; diff --git a/src/renderer/assets/FlojoyTheme.ts b/src/renderer/assets/FlojoyTheme.ts index bc21231c8..81d2b32b2 100644 --- a/src/renderer/assets/FlojoyTheme.ts +++ b/src/renderer/assets/FlojoyTheme.ts @@ -12,7 +12,7 @@ export const flojoySyntaxTheme: SyntaxTheme = { background: "rgb(var(--color-modal))", }, "hljs-comment": { - color: "rgb(var(--foreground))", + color: "rgb(var(--color-accent4))", fontStyle: "italic", }, "hljs-quote": { diff --git a/src/renderer/hooks/useTestSequencerProject.ts b/src/renderer/hooks/useTestSequencerProject.ts index 14bc58454..ed2b08236 100644 --- a/src/renderer/hooks/useTestSequencerProject.ts +++ b/src/renderer/hooks/useTestSequencerProject.ts @@ -118,6 +118,61 @@ export const useImportSequences = () => { return handleImport; }; +export const useImportAllSequencesInFolder = () => { + const manager = usePrepareStateManager(); + const { isAdmin } = useWithPermission(); + + const handleImport = async (path: string, relative: boolean = false) => { + async function importSequences(): Promise> { + // Confirmation if admin + if (!isAdmin()) { + return err( + Error( + "Admin only, Connect to Flojoy Cloud and select a Test Profile", + ), + ); + } + + // Find .tjoy files from the profile + const result = await window.api.openAllFilesInFolder( + path, + ["tjoy"], + relative, + ); + if (result === undefined) { + return err(Error(`Failed to find the directory ${path}`)); + } + if (!result || result.length === 0) { + return err(Error("No .tjoy file found in the selected directory")); + } + + // Import them in the sequencer + await Promise.all( + result.map(async (res, idx) => { + const { filePath, fileContent } = res; + const result = await importSequence( + filePath, + fileContent, + manager, + idx !== 0, + ); + if (result.isErr()) return err(result.error); + }), + ); + + return ok(undefined); + } + + toastResultPromise(importSequences(), { + loading: `Importing Sequences...`, + success: () => `Sequences imported`, + error: (e) => `${e}`, + }); + }; + + return handleImport; +}; + export const useLoadTestProfile = () => { const manager = usePrepareStateManager(); const { isAdmin } = useWithPermission(); diff --git a/src/renderer/routes/test_sequencer_panel/components/DesignBar.tsx b/src/renderer/routes/test_sequencer_panel/components/DesignBar.tsx index de4b98f31..249e9bb84 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 { SequencerGalleryModal } from "./modals/SequencerGalleryModal"; export function DesignBar() { const { setIsImportTestModalOpen, setIsCreateProjectModalOpen } = @@ -63,9 +64,14 @@ export function DesignBar() { }, [elems, sequences, cycleRuns]); const [displayTotal, setDisplayTotal] = useState(false); + const [isGalleryOpen, setIsGalleryOpen] = useState(false); return (
+
{isAdmin() && ( @@ -110,15 +116,23 @@ export function DesignBar() { Import Sequence - + setIsGalleryOpen(true)} + data-testid="seq-gallery-btn" + > - Sequence Gallery + Import Example + +
)} 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 402f8696c..d04b44300 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 @@ -267,7 +267,10 @@ export function TestTable() { header: () =>
Status
, cell: ({ row }) => { return row.original.type === "test" ? ( -
+
{typeof mapStatusToDisplay[row.original.status] === "function" ? mapStatusToDisplay[row.original.status](row.original.error) : mapStatusToDisplay[row.original.status]} diff --git a/src/renderer/routes/test_sequencer_panel/components/modals/SequencerGalleryModal.tsx b/src/renderer/routes/test_sequencer_panel/components/modals/SequencerGalleryModal.tsx new file mode 100644 index 000000000..071f387ea --- /dev/null +++ b/src/renderer/routes/test_sequencer_panel/components/modals/SequencerGalleryModal.tsx @@ -0,0 +1,88 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/renderer/components/ui/dialog"; +import { ScrollArea } from "@/renderer/components/ui/scroll-area"; +import { Separator } from "@/renderer/components/ui/separator"; +import { Button } from "@/renderer/components/ui/button"; +import { useImportAllSequencesInFolder } from "@/renderer/hooks/useTestSequencerProject"; + +type SequencerGalleryModalProps = { + isGalleryOpen: boolean; + setIsGalleryOpen: (open: boolean) => void; +}; + +export const SequencerGalleryModal = ({ + isGalleryOpen, + setIsGalleryOpen, +}: SequencerGalleryModalProps) => { + const importSequence = useImportAllSequencesInFolder(); + + const handleSequenceLoad = (relativePath: string) => { + importSequence(relativePath, true); + setIsGalleryOpen(false); + }; + + const data = [ + { + title: "Creating Sequences with Conditional", + description: + "Learn how to create a simple sequence with conditional logic.", + dirPath: "examples/test-sequencer-conditional-example/", + }, + { + title: "Test Step with Expected and Exported Values", + description: + "Learn how to inject the minimum and maximum expected values into a test and export the result.", + dirPath: "examples/test-sequencer-expected-exported-example/", + }, + ]; + + return ( + + + + +
Sequence Gallery
+
+
+ + {data.map((seqExample) => ( + <> + +
+
+
+
+ {seqExample.title} +
+
+ {seqExample.description} +
+
+
+
+ +
+ + ))} + + +
+ ); +}; diff --git a/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts b/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts index bb539dabe..fe5dcfd3f 100644 --- a/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts +++ b/src/renderer/routes/test_sequencer_panel/utils/SequenceHandler.ts @@ -229,6 +229,9 @@ async function saveToDisk( } async function installDeps(sequence: TestSequencerProject): Promise { + if (sequence.interpreter.requirementsPath === null) { + return true; + } const success = await window.api.poetryInstallRequirementsUserGroup( sequence.projectPath + sequence.interpreter.requirementsPath, ); diff --git a/src/renderer/types/test-sequencer.ts b/src/renderer/types/test-sequencer.ts index a711fdf3e..9041fe679 100644 --- a/src/renderer/types/test-sequencer.ts +++ b/src/renderer/types/test-sequencer.ts @@ -145,8 +145,8 @@ export type InterpreterType = z.infer; export const Interpreter = z.object({ type: InterpreterType, - path: z.union([z.null(), z.string()]), - requirementsPath: z.union([z.null(), z.string()]), + path: z.string().nullable(), + requirementsPath: z.string().nullable(), }); export type Interpreter = z.infer;