diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5419be8..f30b77b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,9 +14,9 @@ jobs: npm i -g corepack@latest corepack enable - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 registry-url: "https://registry.npmjs.org" cache: "pnpm" diff --git a/.github/workflows/ts.yml b/.github/workflows/ts.yml index 7be9bc8..490afef 100644 --- a/.github/workflows/ts.yml +++ b/.github/workflows/ts.yml @@ -22,9 +22,9 @@ jobs: npm i -g corepack@latest corepack enable - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: "pnpm" - run: pnpm i @@ -44,9 +44,9 @@ jobs: corepack enable - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: "pnpm" - run: pnpm i @@ -64,9 +64,9 @@ jobs: npm i -g corepack@latest corepack enable - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: "pnpm" - run: pnpm i diff --git a/README.md b/README.md index 64dc2e2..6c34c49 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ This way even entityIds like environmentIds or testCaseIds will be autocompleted # octomind -Octomind cli tool. Version: 4.0.0. Additional documentation see https://octomind.dev/docs/api-reference/ +Octomind cli tool. Version: 4.1.0. Additional documentation see https://octomind.dev/docs/api-reference/ **Usage:** `octomind [options] [command]` @@ -445,6 +445,20 @@ Push local YAML test cases to the test target | `-j, --json` | Output raw JSON response | No | | | `-t, --test-target-id [id]` | Test target ID, if not provided will use the test target id from the config | No | | +## edit-test-case + +Edit yaml test case + +**Usage:** `edit-test-case [options]` + +### Options + +| Option | Description | Required | Default | +|:-------|:----------|:---------|:--------| +| `-j, --json` | Output raw JSON response | No | | +| `-t, --test-target-id [id]` | Test target ID, if not provided will use the test target id from the config | No | | +| `-f, --file-path ` | The path to the local yaml file you want to edit | Yes | | + ## Test Reports ## test-report diff --git a/package.json b/package.json index a773845..8eaea63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@octomind/octomind", - "version": "4.0.0", + "version": "4.1.0", "description": "a command line client for octomind apis", "main": "./dist/index.js", "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402", @@ -41,7 +41,10 @@ "@playwright/test": "1.57.0", "@types/shell-quote": "1.7.5", "commander": "14.0.2", + "diff": "8.0.3", + "open": "11.0.0", "openapi-fetch": "0.15.0", + "ora": "9.0.0", "otplib": "13.1.0", "shell-quote": "1.8.3", "simple-git": "3.30.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f48cfa..a3600c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,18 @@ importers: commander: specifier: 14.0.2 version: 14.0.2 + diff: + specifier: 8.0.3 + version: 8.0.3 + open: + specifier: 11.0.0 + version: 11.0.0 openapi-fetch: specifier: 0.15.0 version: 0.15.0 + ora: + specifier: 9.0.0 + version: 9.0.0 otplib: specifier: 13.1.0 version: 13.1.0 @@ -948,6 +957,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -1006,6 +1019,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1033,6 +1050,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} @@ -1047,6 +1068,14 @@ packages: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-width@2.2.1: resolution: {integrity: sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==} @@ -1121,10 +1150,22 @@ packages: supports-color: optional: true + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.4.0: + resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -1133,6 +1174,10 @@ packages: resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} engines: {node: '>= 0.6.0'} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1329,6 +1374,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1469,6 +1518,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1493,6 +1547,19 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -1537,6 +1604,10 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -1549,6 +1620,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1639,6 +1714,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + loglevel-plugin-prefix@0.8.4: resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} @@ -1682,6 +1761,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1781,6 +1864,14 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + openapi-fetch@0.15.0: resolution: {integrity: sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==} @@ -1799,6 +1890,10 @@ packages: openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + ora@9.0.0: + resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} + engines: {node: '>=20'} + orval@7.18.0: resolution: {integrity: sha512-mPGSQeAAxhvRoxMKn22vmmtFa5eoJiwTBUSsrwKjKjC+rJdA3eWOLRiU1ICq2lep22S+3qpEy5WizP5FW26+rg==} engines: {node: '>=22.18.0'} @@ -1886,6 +1981,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1929,6 +2028,10 @@ packages: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1938,6 +2041,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -2034,6 +2141,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-eval@1.0.1: resolution: {integrity: sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==} engines: {node: '>=12'} @@ -2055,6 +2166,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2071,6 +2186,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -2098,6 +2217,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -2397,6 +2520,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2425,6 +2552,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} @@ -3275,6 +3406,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -3331,6 +3464,10 @@ snapshots: dependencies: fill-range: 7.1.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3363,6 +3500,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + change-case@5.4.4: {} chardet@0.7.0: {} @@ -3375,6 +3514,12 @@ snapshots: dependencies: restore-cursor: 2.0.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.4.0: {} + cli-width@2.2.1: {} cliui@8.0.1: @@ -3441,12 +3586,21 @@ snapshots: optionalDependencies: supports-color: 10.2.2 + default-browser-id@5.0.1: {} + + default-browser@5.4.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -3455,6 +3609,8 @@ snapshots: dependency-graph@0.11.0: {} + diff@8.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -3757,6 +3913,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3918,6 +4076,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -3940,6 +4100,14 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -3981,6 +4149,8 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -3992,6 +4162,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -4062,6 +4236,11 @@ snapshots: lodash@4.17.21: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + loglevel-plugin-prefix@0.8.4: {} loglevel@1.9.2: {} @@ -4098,6 +4277,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4208,6 +4389,19 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.4.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + openapi-fetch@0.15.0: dependencies: openapi-typescript-helpers: 0.0.15 @@ -4230,6 +4424,18 @@ snapshots: dependencies: yaml: 2.8.2 + ora@9.0.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.2.2 + string-width: 8.1.0 + strip-ansi: 7.1.2 + orval@7.18.0(openapi-types@12.1.3)(typescript@5.9.3): dependencies: '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) @@ -4337,6 +4543,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + process-nextick-args@2.0.1: {} punycode.js@2.3.1: {} @@ -4388,6 +4596,11 @@ snapshots: onetime: 2.0.1 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + reusify@1.1.0: {} rollup@4.55.1: @@ -4421,6 +4634,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + run-applescript@7.1.0: {} + run-async@2.4.1: {} run-parallel@1.2.0: @@ -4544,6 +4759,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-eval@1.0.1: dependencies: jsep: 1.4.0 @@ -4564,6 +4781,8 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.2.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4582,6 +4801,11 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -4621,6 +4845,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-final-newline@2.0.0: {} supports-color@10.2.2: {} @@ -4927,6 +5155,11 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.0 + powershell-utils: 0.1.0 + y18n@5.0.8: {} yaml-ast-parser@0.0.43: {} @@ -4949,4 +5182,6 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zod@4.3.5: {} diff --git a/src/cli.ts b/src/cli.ts index 3ffc061..ed6e970 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,7 @@ import { } from "./tools"; import { init, switchTestTarget } from "./tools/init"; import { update } from "./tools/update"; +import { edit } from "./tools/yamlMutations/edit"; import { version } from "./version"; export const BINARY_NAME = "octomind"; @@ -401,6 +402,18 @@ export const buildCmd = (): CompletableCommand => { .addOption(testTargetIdOption) .action(addTestTargetWrapper(pushTestTarget)); + // noinspection RequiredAttributes + createCommandWithCommonOptions(program, "edit-test-case") + .completer(testTargetIdCompleter) + .description("Edit yaml test case") + .helpGroup("test-cases") + .addOption(testTargetIdOption) + .requiredOption( + "-f, --file-path ", + "The path to the local yaml file you want to edit", + ) + .action(addTestTargetWrapper(edit)); + createCommandWithCommonOptions(program, "list-test-targets") .description("List all test targets") .helpGroup("test-targets") diff --git a/src/debugtopus/index.ts b/src/debugtopus/index.ts index e72f141..4de0b5e 100644 --- a/src/debugtopus/index.ts +++ b/src/debugtopus/index.ts @@ -19,7 +19,7 @@ import { getTestCases, } from "../tools"; import { client, handleError } from "../tools/client"; -import { readTestCasesFromDir } from "../tools/sync/yml"; +import { readTestCasesFromDir } from "../tools/sync/yaml"; import { ensureChromiumIsInstalled } from "./installation"; export type DebugtopusOptions = { diff --git a/src/helpers.ts b/src/helpers.ts index 06fd58e..11b7b80 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -68,12 +68,35 @@ export const getAbsoluteFilePathInOctomindRoot = async ({ filePath: string; octomindRoot: string; }): Promise => { - try { - const resolvedPath = await fsPromises.realpath( - path.isAbsolute(filePath) ? filePath : path.join(octomindRoot, filePath), - ); - return resolvedPath.startsWith(octomindRoot) ? resolvedPath : null; - } catch { - return null; + const isWithinOctomindRoot = (p: string) => p.startsWith(octomindRoot); + + const isFile = async (p: string): Promise => { + try { + const stats = await fsPromises.stat(p); + return stats.isFile(); + } catch { + return false; + } + }; + + if (path.isAbsolute(filePath)) { + return isWithinOctomindRoot(filePath) ? filePath : null; } + + // For relative paths, try resolving from cwd first, then from octomindRoot + const candidates = [ + path.resolve(filePath), + path.resolve(octomindRoot, filePath), + ]; + + for (const candidate of candidates) { + if (isWithinOctomindRoot(candidate) && (await isFile(candidate))) { + return candidate; + } + } + + return null; }; + +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/tools/sync/push.ts b/src/tools/sync/push.ts index 173f548..ea034cf 100644 --- a/src/tools/sync/push.ts +++ b/src/tools/sync/push.ts @@ -5,7 +5,7 @@ import { ListOptions } from "../client"; import { checkForConsistency } from "./consistency"; import { getGitContext } from "./git"; import { TestTargetSyncData } from "./types"; -import { readTestCasesFromDir } from "./yml"; +import { readTestCasesFromDir } from "./yaml"; type ErrorResponse = | components["schemas"]["ZodResponse"] @@ -68,7 +68,14 @@ const defaultPush = async ( export const draftPush = async ( body: TestTargetSyncData, options: Omit & ListOptions, -): Promise<{ success: boolean; versionIds: string[] } | undefined> => { +): Promise< + | { + success: boolean; + versionIds: string[]; + versionIdByStableId: Record; + } + | undefined +> => { const { data, error } = await options.client.POST( "/apiKey/beta/test-targets/{testTargetId}/draft/push", { diff --git a/src/tools/sync/yml.ts b/src/tools/sync/yaml.ts similarity index 88% rename from src/tools/sync/yml.ts rename to src/tools/sync/yaml.ts index f0aa73e..f7e89cb 100644 --- a/src/tools/sync/yml.ts +++ b/src/tools/sync/yaml.ts @@ -1,4 +1,5 @@ import fs from "fs"; +import fsPromises from "fs/promises"; import path from "path"; import yaml from "yaml"; @@ -6,6 +7,8 @@ import yaml from "yaml"; import { pushTestTargetBody } from "../../schemas/octomindExternalAPI"; import { SyncTestCase, TestTargetSyncData } from "./types"; +const syncTestCaseSchema = pushTestTargetBody.shape.testCases.element; + const removeDiacritics = (str: string): string => { // diacritics lead to issues in the file system afterward, cf. https://www.reddit.com/r/MacOS/comments/jhjv41/psa_beware_of_umlauts_and_other_accented/ return str.normalize("NFKD").replace(/[\u0300-\u036f]/g, ""); @@ -40,7 +43,20 @@ const toFileSystemCompatibleCamelCase = (description: string): string => { return camelCased; }; -export const writeYaml = (data: TestTargetSyncData, destination?: string) => { +export const writeSingleTestCaseYaml = async ( + filePath: string, + testCase: SyncTestCase, +): Promise => { + return fsPromises.writeFile( + filePath, + `# yaml-language-server: $schema=https://app.octomind.dev/schemas/SyncTestCaseSchema.json\n${yaml.stringify(testCase)}`, + ); +}; + +export const writeYaml = async ( + data: TestTargetSyncData, + destination?: string, +): Promise => { cleanupFilesystem({ newTestCases: data.testCases, destination, @@ -50,9 +66,9 @@ export const writeYaml = (data: TestTargetSyncData, destination?: string) => { const folderName = buildFolderName(testCase, data.testCases, destination); const testCaseFilename = buildFilename(testCase, folderName); fs.mkdirSync(folderName, { recursive: true }); - fs.writeFileSync( + await writeSingleTestCaseYaml( path.join(folderName, testCaseFilename), - `# yaml-language-server: $schema=https://app.octomind.dev/schemas/SyncTestCaseSchema.json\n${yaml.stringify(testCase)}`, + testCase, ); } }; @@ -167,21 +183,21 @@ export const readTestCasesFromDir = (startDir: string): SyncTestCase[] => { for (const file of yamlFiles) { try { const content = fs.readFileSync(file, "utf8"); - const parsed = yaml.parse(content); - testCases.push(parsed); + const raw = yaml.parse(content); + const result = syncTestCaseSchema.safeParse(raw); + + if (result.success) { + testCases.push(result.data); + } else { + console.warn( + `Failed to read test case from ${file}: ${result.error.message}`, + ); + } } catch { console.error(`Failed to read test case from ${file}`); } } - const result = pushTestTargetBody.safeParse({ testCases }); - - if (!result.success) { - throw new Error( - `Failed to parse test cases from ${startDir}: ${result.error.message}`, - ); - } - return testCases; }; diff --git a/src/tools/test-cases.ts b/src/tools/test-cases.ts index c831fe0..742fb06 100644 --- a/src/tools/test-cases.ts +++ b/src/tools/test-cases.ts @@ -6,7 +6,7 @@ import { findOctomindFolder } from "../helpers"; import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { getEnvironments } from "./environments"; -import { buildFilename, readTestCasesFromDir } from "./sync/yml"; +import { buildFilename, readTestCasesFromDir } from "./sync/yaml"; export type TestCaseResponse = components["schemas"]["TestCaseResponse"]; export type TestCasesResponse = components["schemas"]["TestCasesResponse"]; diff --git a/src/tools/test-targets.ts b/src/tools/test-targets.ts index 77b185f..62733d4 100644 --- a/src/tools/test-targets.ts +++ b/src/tools/test-targets.ts @@ -5,7 +5,7 @@ import { findOctomindFolder } from "../helpers"; import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { push } from "./sync/push"; -import { writeYaml } from "./sync/yml"; +import { writeYaml } from "./sync/yaml"; export const getTestTargets = async () => { const { data, error } = await client.GET("/apiKey/v3/test-targets"); @@ -73,7 +73,7 @@ export const pullTestTarget = async ( const destination = (await findOctomindFolder()) ?? path.join(process.cwd(), OCTOMIND_FOLDER_NAME); - writeYaml(data, destination); + await writeYaml(data, destination); console.log("Test Target pulled successfully"); }; diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts new file mode 100644 index 0000000..9d2cf4f --- /dev/null +++ b/src/tools/yamlMutations/edit.ts @@ -0,0 +1,137 @@ +import fs from "fs"; + +import { createTwoFilesPatch } from "diff"; +import open from "open"; +import ora from "ora"; +import yaml from "yaml"; + +import { OCTOMIND_FOLDER_NAME } from "../../constants"; +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, + sleep, +} from "../../helpers"; +import { BASE_URL, client, handleError } from "../client"; +import { checkForConsistency } from "../sync/consistency"; +import { draftPush } from "../sync/push"; +import { SyncTestCase } from "../sync/types"; +import { readTestCasesFromDir, writeSingleTestCaseYaml } from "../sync/yaml"; +import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; + +type EditOptions = { + testTargetId: string; + filePath: string; +}; + +const getRelevantTestCases = ( + testCasesById: Record, + startTestCase: SyncTestCase, +): SyncTestCase[] => { + let dependencyId = startTestCase.dependencyId; + const result: SyncTestCase[] = [startTestCase]; + + while (dependencyId) { + const currentTestCase = testCasesById[dependencyId]; + + if (!currentTestCase) { + throw new Error( + `Could not find dependency ${dependencyId} for ${startTestCase.id}`, + ); + } + + result.push(currentTestCase); + dependencyId = currentTestCase?.dependencyId; + } + + return result; +}; + +const loadTestCase = (testCasePath: string): SyncTestCase => { + try { + const content = fs.readFileSync(testCasePath, "utf8"); + return yaml.parse(content); + } catch (error) { + throw new Error(`Could not parse ${testCasePath}: ${error}`); + } +}; + +export const edit = async (options: EditOptions): Promise => { + const octomindRoot = await findOctomindFolder(); + if (!octomindRoot) { + throw new Error( + `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to edit`, + ); + } + + const testCaseFilePath = await getAbsoluteFilePathInOctomindRoot({ + octomindRoot, + filePath: options.filePath, + }); + + if (!testCaseFilePath) { + throw new Error( + `Could not find ${options.filePath} in folder ${octomindRoot}`, + ); + } + + const originalTestCase = loadTestCase(testCaseFilePath); + const testCaseToEdit = { + ...originalTestCase, + localEditingStatus: "IN_PROGRESS" as const, + }; + + const testCases = readTestCasesFromDir(octomindRoot); + const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); + const relevantTestCases = getRelevantTestCases(testCasesById, testCaseToEdit); + checkForConsistency(relevantTestCases); + + const response = await draftPush( + { + testCases: relevantTestCases, + }, + { + testTargetId: options.testTargetId, + client, + onError: handleError, + }, + ); + + if (!response) { + throw new Error(`Could not edit test case with id '${testCaseToEdit.id}'`); + } + + const versionId = response.versionIdByStableId[testCaseToEdit.id]; + if (!versionId) { + throw new Error(`Could not edit test case with id '${testCaseToEdit.id}'`); + } + const parsedBaseUrl = URL.parse(BASE_URL); + const localEditingUrl = `${parsedBaseUrl?.protocol}//${parsedBaseUrl?.host}/testtargets/${options.testTargetId}/testcases/${versionId}/localEdit?detailsPanelRail=steps&testTargetId=${options.testTargetId}&testCaseId=${versionId}`; + await open(localEditingUrl); + + console.log( + `Navigating to local editing url, open it manually if a browser didn't open already: ${localEditingUrl}`, + ); + + const editResult = await waitForLocalChangesToBeFinished( + versionId, + testCaseToEdit, + options, + ); + + if (editResult === "cancelled") { + console.log("Cancelled editing test case, exiting"); + return; + } + + await writeSingleTestCaseYaml(testCaseFilePath, editResult); + + const diff = createTwoFilesPatch( + "old.yaml", + "new.yaml", + yaml.stringify(originalTestCase), + yaml.stringify(editResult), + ); + + console.log(`Edited test case successfully`); + console.log(diff); +}; diff --git a/src/tools/yamlMutations/waitForLocalChanges.ts b/src/tools/yamlMutations/waitForLocalChanges.ts new file mode 100644 index 0000000..c5a5171 --- /dev/null +++ b/src/tools/yamlMutations/waitForLocalChanges.ts @@ -0,0 +1,77 @@ +import ora from "ora"; + +import { sleep } from "../../helpers"; +import { client } from "../client"; +import { SyncTestCase } from "../sync/types"; + +const POLLING_INTERVAL = 1000; +const getTestCaseVersion = async ( + versionId: string, + testCase: SyncTestCase, + options: { testTargetId: string }, +) => { + return await client.GET( + "/apiKey/beta/test-targets/{testTargetId}/test-cases/{testCaseId}/versions/{versionId}", + { + params: { + path: { + versionId, + testCaseId: testCase.id, + testTargetId: options.testTargetId, + }, + }, + }, + ); +}; + +export const waitForLocalChangesToBeFinished = async ( + versionId: string, + testCaseToEdit: SyncTestCase, + options: { testTargetId: string }, +): Promise => { + let localTestCase = await getTestCaseVersion( + versionId, + testCaseToEdit, + options, + ); + + if (!localTestCase.data) { + throw new Error( + `Could not get local editing status for test case ${testCaseToEdit.id}`, + ); + } + + const throbber = ora("Waiting for editing to finish in UI").start(); + while (localTestCase.data.localEditingStatus === "IN_PROGRESS") { + await sleep(POLLING_INTERVAL); + + localTestCase = await getTestCaseVersion( + versionId, + testCaseToEdit, + options, + ); + if (!localTestCase.data) { + throw new Error( + `Could not get local editing status for test case ${testCaseToEdit.id}`, + ); + } + + if (localTestCase.data.localEditingStatus === "CANCELLED") { + throbber.fail("cancelled by user"); + return "cancelled"; + } + } + + throbber.succeed("Finished editing in UI"); + + const syncTestCaseWithoutExtraProperties: SyncTestCase & { + versionId: undefined; + } = { + ...localTestCase.data, + id: testCaseToEdit.id, + localEditingStatus: undefined, + versionId: undefined, + }; + + return syncTestCaseWithoutExtraProperties; +}; diff --git a/tests/debugtopus/index.spec.ts b/tests/debugtopus/index.spec.ts index 328ab7f..43c797e 100644 --- a/tests/debugtopus/index.spec.ts +++ b/tests/debugtopus/index.spec.ts @@ -17,14 +17,14 @@ import { ensureChromiumIsInstalled } from "../../src/debugtopus/installation"; import { findOctomindFolder } from "../../src/helpers"; import { client } from "../../src/tools/client"; import { getPlaywrightConfig } from "../../src/tools/playwright"; -import { readTestCasesFromDir } from "../../src/tools/sync/yml"; +import { readTestCasesFromDir } from "../../src/tools/sync/yaml"; import { createMockSyncTestCase } from "../mocks"; vi.mock("fs/promises"); vi.mock("fs"); vi.mock("unzipper"); vi.mock("../../src/tools/client"); -vi.mock("../../src/tools/sync/yml"); +vi.mock("../../src/tools/sync/yaml"); vi.mock("../../src/tools/playwright"); vi.mock("../../src/debugtopus/installation"); vi.mock("../../src/helpers"); diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts index b09dff3..cd1c92a 100644 --- a/tests/helpers.spec.ts +++ b/tests/helpers.spec.ts @@ -134,9 +134,17 @@ describe("helpers", () => { expect(result).toBe(filePath); }); + it("should return null for non-existent relative path", async () => { + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: "does-not-exist.yaml", + octomindRoot, + }); + + expect(result).toBeNull(); + }); + it("should accept absolute path within octomind root", async () => { const filePath = path.join(octomindRoot, "test-case.yaml"); - await fsPromises.writeFile(filePath, ""); const result = await getAbsoluteFilePathInOctomindRoot({ filePath, @@ -148,7 +156,6 @@ describe("helpers", () => { it("should return null for absolute path outside octomind root", async () => { const outsideFile = path.join(tmpDir, "outside.yaml"); - await fsPromises.writeFile(outsideFile, ""); const result = await getAbsoluteFilePathInOctomindRoot({ filePath: outsideFile, @@ -159,9 +166,6 @@ describe("helpers", () => { }); it("should return null for path traversal attempts", async () => { - const outsideFile = path.join(tmpDir, "outside.yaml"); - await fsPromises.writeFile(outsideFile, ""); - const result = await getAbsoluteFilePathInOctomindRoot({ filePath: "../outside.yaml", octomindRoot, @@ -169,31 +173,5 @@ describe("helpers", () => { expect(result).toBeNull(); }); - - it("should return null for non-existent file", async () => { - const result = await getAbsoluteFilePathInOctomindRoot({ - filePath: "does-not-exist.yaml", - octomindRoot, - }); - - expect(result).toBeNull(); - }); - - it("should return null for malformed path with nested octomind structure", async () => { - // e.g. /some/folder/.octomind/some/folder/.octomind/a.yaml - const malformedPath = path.join( - octomindRoot, - tmpDir, - OCTOMIND_FOLDER_NAME, - "a.yaml", - ); - - const result = await getAbsoluteFilePathInOctomindRoot({ - filePath: malformedPath, - octomindRoot, - }); - - expect(result).toBeNull(); - }); }); }); diff --git a/tests/tools/sync/push.spec.ts b/tests/tools/sync/push.spec.ts index f12c7da..cc371a5 100644 --- a/tests/tools/sync/push.spec.ts +++ b/tests/tools/sync/push.spec.ts @@ -4,10 +4,10 @@ import { DeepMockProxy, mock, mockDeep } from "vitest-mock-extended"; import { client } from "../../../src/tools/client"; import { getGitContext } from "../../../src/tools/sync/git"; import { push } from "../../../src/tools/sync/push"; -import { readTestCasesFromDir } from "../../../src/tools/sync/yml"; +import { readTestCasesFromDir } from "../../../src/tools/sync/yaml"; vi.mock("../../../src/tools/sync/git"); -vi.mock("../../../src/tools/sync/yml"); +vi.mock("../../../src/tools/sync/yaml"); describe("push", () => { let mockedClient: DeepMockProxy; diff --git a/tests/tools/sync/yaml.spec.ts b/tests/tools/sync/yaml.spec.ts index 8688b30..d143e02 100644 --- a/tests/tools/sync/yaml.spec.ts +++ b/tests/tools/sync/yaml.spec.ts @@ -10,10 +10,10 @@ import { buildFolderName, cleanupFilesystem, readTestCasesFromDir, -} from "../../../src/tools/sync/yml"; +} from "../../../src/tools/sync/yaml"; import { createMockSyncTestCase } from "../../mocks"; -describe("yml", () => { +describe("yaml", () => { let tmpDir: string; beforeEach(() => { @@ -193,7 +193,7 @@ describe("yml", () => { expect(readTestCasesFromDir(tmpDir)).toEqual([testCase]); }); - it("should throw on an invalid test case", () => { + it("should skip invalid test cases and log an error", () => { fs.mkdirSync(path.join(tmpDir, "test1")); const testCase = createMockSyncTestCase({ @@ -204,7 +204,8 @@ describe("yml", () => { yaml.stringify(testCase), ); - expect(() => readTestCasesFromDir(tmpDir)).toThrow(/Invalid UUID/); + const result = readTestCasesFromDir(tmpDir); + expect(result).toEqual([]); }); }); diff --git a/tests/tools/test-cases.spec.ts b/tests/tools/test-cases.spec.ts index 2ab7b8b..0731b48 100644 --- a/tests/tools/test-cases.spec.ts +++ b/tests/tools/test-cases.spec.ts @@ -6,12 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { findOctomindFolder } from "../../src/helpers"; import { client, handleError } from "../../src/tools/client"; -import { buildFilename, readTestCasesFromDir } from "../../src/tools/sync/yml"; +import { buildFilename, readTestCasesFromDir } from "../../src/tools/sync/yaml"; import { deleteTestCase } from "../../src/tools/test-cases"; vi.mock("../../src/tools/client"); vi.mock("../../src/helpers"); -vi.mock("../../src/tools/sync/yml"); +vi.mock("../../src/tools/sync/yaml"); describe("test-cases", () => { let clientDELETE: Mock; diff --git a/tests/tools/test-targets.spec.ts b/tests/tools/test-targets.spec.ts index 2e7de09..c44b312 100644 --- a/tests/tools/test-targets.spec.ts +++ b/tests/tools/test-targets.spec.ts @@ -5,11 +5,11 @@ import { findOctomindFolder } from "../../src/helpers"; import { pushTestTarget } from "../../src/tools"; import { client } from "../../src/tools/client"; import { getGitContext } from "../../src/tools/sync/git"; -import { readTestCasesFromDir } from "../../src/tools/sync/yml"; +import { readTestCasesFromDir } from "../../src/tools/sync/yaml"; vi.mock("../../src/helpers"); vi.mock("../../src/tools/sync/git"); -vi.mock("../../src/tools/sync/yml"); +vi.mock("../../src/tools/sync/yaml"); vi.mock("../../src/tools/client"); describe("push", () => { diff --git a/tests/tools/yamlMutations/edit.spec.ts b/tests/tools/yamlMutations/edit.spec.ts new file mode 100644 index 0000000..b003621 --- /dev/null +++ b/tests/tools/yamlMutations/edit.spec.ts @@ -0,0 +1,230 @@ +import fs from "fs"; + +import ora from "ora"; +import { beforeEach, describe, expect, it, MockedObject, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; +import yaml from "yaml"; + +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../../src/helpers"; +import { client } from "../../../src/tools/client"; +import { draftPush } from "../../../src/tools/sync/push"; +import { readTestCasesFromDir } from "../../../src/tools/sync/yaml"; +import { edit } from "../../../src/tools/yamlMutations/edit"; +import { waitForLocalChangesToBeFinished } from "../../../src/tools/yamlMutations/waitForLocalChanges"; +import { createMockSyncTestCase } from "../../mocks"; + +vi.mock("fs"); +vi.mock("open"); +vi.mock("../../../src/helpers"); +vi.mock("../../../src/tools/client"); +vi.mock("../../../src/tools/sync/push"); +vi.mock("../../../src/tools/sync/yaml"); +vi.mock("../../../src/tools/sync/consistency"); +vi.mock("../../../src/tools/yamlMutations/waitForLocalChanges"); + +describe("edit", () => { + let mockedClient: MockedObject; + + beforeEach(() => { + console.log = vi.fn(); + + mockedClient = vi.mocked(client); + + vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue( + createMockSyncTestCase({ id: "test-id" }), + ); + + vi.mocked(readTestCasesFromDir).mockReturnValue([]); + }); + + it("throws if octomind folder is not found", async () => { + vi.mocked(findOctomindFolder).mockResolvedValue(null); + + await expect( + edit({ testTargetId: "someId", filePath: "test.yaml" }), + ).rejects.toThrow("Could not find .octomind folder"); + }); + + it("throws if file path is not found", async () => { + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue(null); + + await expect( + edit({ testTargetId: "someId", filePath: "missing.yaml" }), + ).rejects.toThrow("Could not find missing.yaml"); + }); + + it("throws if test case file cannot be parsed", async () => { + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/test.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue("this: is: invalid: "); + + await expect( + edit({ testTargetId: "someId", filePath: "test.yaml" }), + ).rejects.toThrow("Could not parse"); + }); + + it("throws if draftPush returns no response", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/test.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); + vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); + vi.mocked(draftPush).mockResolvedValue(undefined); + + await expect( + edit({ testTargetId: "someId", filePath: "test.yaml" }), + ).rejects.toThrow("Could not edit test case with id 'test-id'"); + }); + + it("throws if versionId is not returned for test case", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/test.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); + vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); + vi.mocked(draftPush).mockResolvedValue({ + success: true, + versionIds: [], + versionIdByStableId: {}, + }); + + await expect( + edit({ testTargetId: "someId", filePath: "test.yaml" }), + ).rejects.toThrow("Could not edit test case with id 'test-id'"); + }); + + it("exits gracefully when editing is cancelled", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/test.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); + vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); + vi.mocked(draftPush).mockResolvedValue({ + success: true, + versionIds: [], + versionIdByStableId: { "test-id": "version-123" }, + }); + vi.mocked(mockedClient.GET) + .mockResolvedValueOnce({ + data: { localEditingStatus: "IN_PROGRESS" }, + error: undefined, + response: mock(), + }) + .mockResolvedValueOnce({ + data: { localEditingStatus: "CANCELLED" }, + error: undefined, + response: mock(), + }); + vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue("cancelled"); + + await edit({ testTargetId: "someId", filePath: "test.yaml" }); + + expect(console.log).toHaveBeenCalledWith( + "Cancelled editing test case, exiting", + ); + }); + + it("includes dependency chain in relevant test cases", async () => { + const parentTestCase = createMockSyncTestCase({ id: "parent-id" }); + const childTestCase = createMockSyncTestCase({ + id: "child-id", + dependencyId: "parent-id", + }); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/child.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(childTestCase)); + vi.mocked(readTestCasesFromDir).mockReturnValue([ + parentTestCase, + childTestCase, + ]); + vi.mocked(draftPush).mockResolvedValue({ + success: true, + versionIds: [], + versionIdByStableId: { "child-id": "version-123" }, + }); + vi.mocked(mockedClient.GET).mockResolvedValue({ + data: { localEditingStatus: "CANCELLED" }, + error: undefined, + response: mock(), + }); + + await edit({ testTargetId: "someId", filePath: "child.yaml" }); + + expect(draftPush).toHaveBeenCalledWith( + expect.objectContaining({ + testCases: expect.arrayContaining([ + expect.objectContaining({ id: "child-id" }), + expect.objectContaining({ id: "parent-id" }), + ]), + }), + expect.anything(), + ); + }); + + it("throws if dependency is not found", async () => { + const childTestCase = createMockSyncTestCase({ + id: "child-id", + dependencyId: "missing-parent", + }); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/child.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(childTestCase)); + vi.mocked(readTestCasesFromDir).mockReturnValue([childTestCase]); + + await expect( + edit({ testTargetId: "someId", filePath: "child.yaml" }), + ).rejects.toThrow("Could not find dependency missing-parent"); + }); + + it("exits gracefully when editing is finished", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/test.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); + vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); + vi.mocked(draftPush).mockResolvedValue({ + success: true, + versionIds: [], + versionIdByStableId: { "test-id": "version-123" }, + }); + vi.mocked(mockedClient.GET) + .mockResolvedValueOnce({ + data: { localEditingStatus: "IN_PROGRESS" }, + error: undefined, + response: mock(), + }) + .mockResolvedValueOnce({ + data: { localEditingStatus: "DONE" }, + error: undefined, + response: mock(), + }); + + await edit({ testTargetId: "someId", filePath: "test.yaml" }); + + expect(console.log).toHaveBeenCalledWith("Edited test case successfully"); + }); +}); diff --git a/tests/tools/yamlMutations/waitForLocalChanges.spec.ts b/tests/tools/yamlMutations/waitForLocalChanges.spec.ts new file mode 100644 index 0000000..f5fb69b --- /dev/null +++ b/tests/tools/yamlMutations/waitForLocalChanges.spec.ts @@ -0,0 +1,175 @@ +import { Client } from "openapi-fetch"; +import ora from "ora"; +import { beforeEach, describe, expect, it, MockedObject, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { sleep } from "../../../src/helpers"; +import { client, paths } from "../../../src/tools/client"; +import { SyncTestCase } from "../../../src/tools/sync/types"; +import { waitForLocalChangesToBeFinished } from "../../../src/tools/yamlMutations/waitForLocalChanges"; +import { createMockSyncTestCase } from "../../mocks"; + +vi.mock("ora"); +vi.mock("../../../src/helpers"); +vi.mock("../../../src/tools/client"); + +describe("waitForLocalChangesToBeFinished", () => { + let mockedClient: MockedObject>; + let mockedOraInstance: MockedObject>; + + beforeEach(() => { + mockedClient = vi.mocked(client); + + mockedOraInstance = mock>({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn(), + fail: vi.fn(), + }); + vi.mocked(ora).mockReturnValue(mockedOraInstance); + + vi.mocked(sleep).mockResolvedValue(); + }); + + it("throws if initial GET returns no data", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(mockedClient.GET).mockResolvedValueOnce({ + data: undefined, + error: { message: "error" }, + response: mock(), + }); + + await expect( + waitForLocalChangesToBeFinished("version-123", testCase, { + testTargetId: "someId", + }), + ).rejects.toThrow( + "Could not get local editing status for test case test-id", + ); + }); + + it("throws if GET returns no data during polling", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(mockedClient.GET) + .mockResolvedValueOnce({ + data: { localEditingStatus: "IN_PROGRESS" }, + error: undefined, + response: mock(), + }) + .mockResolvedValueOnce({ + data: undefined, + error: { message: "error" }, + response: mock(), + }); + + await expect( + waitForLocalChangesToBeFinished("version-123", testCase, { + testTargetId: "someId", + }), + ).rejects.toThrow( + "Could not get local editing status for test case test-id", + ); + }); + + it("returns 'cancelled' when localEditingStatus is CANCELLED", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(mockedClient.GET) + .mockResolvedValueOnce({ + data: { localEditingStatus: "IN_PROGRESS" }, + error: undefined, + response: mock(), + }) + .mockResolvedValueOnce({ + data: { localEditingStatus: "CANCELLED" }, + error: undefined, + response: mock(), + }); + + const result = await waitForLocalChangesToBeFinished( + "version-123", + testCase, + { + testTargetId: "someId", + }, + ); + + expect(result).toBe("cancelled"); + expect(mockedOraInstance.fail).toHaveBeenCalledWith("cancelled by user"); + }); + + it("returns updated test case when editing is finished", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(mockedClient.GET).mockResolvedValueOnce({ + data: { + localEditingStatus: "DONE", + description: "updated description", + elements: [], + version: "1", + prompt: "updated prompt", + runStatus: "ON", + }, + error: undefined, + response: mock(), + }); + + const result = await waitForLocalChangesToBeFinished( + "version-123", + testCase, + { + testTargetId: "someId", + }, + ); + + expect(result).toHaveProperty("versionId", undefined); + expect(result).toHaveProperty("localEditingStatus", undefined); + expect(result).toEqual({ + id: "test-id", + description: "updated description", + elements: [], + version: "1", + prompt: "updated prompt", + runStatus: "ON", + }); + expect(mockedOraInstance.succeed).toHaveBeenCalledWith( + "Finished editing in UI", + ); + }); + + it("polls until editing is finished", async () => { + const testCase = createMockSyncTestCase({ id: "test-id" }); + + vi.mocked(mockedClient.GET) + .mockResolvedValueOnce({ + data: { localEditingStatus: "IN_PROGRESS" }, + error: undefined, + response: mock(), + }) + .mockResolvedValueOnce({ + data: { localEditingStatus: "IN_PROGRESS" }, + error: undefined, + response: mock(), + }) + .mockResolvedValueOnce({ + data: { + localEditingStatus: "DONE", + description: "done", + elements: [], + version: "1", + prompt: "prompt", + runStatus: "ON", + }, + error: undefined, + response: mock(), + }); + + await waitForLocalChangesToBeFinished("version-123", testCase, { + testTargetId: "someId", + }); + + expect(sleep).toHaveBeenCalledTimes(2); + expect(mockedClient.GET).toHaveBeenCalledTimes(3); + }); +});