diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..40911a1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + branches: [main, master, testing] + pull_request: + branches: [main, master, testing] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build project + run: bun run build + + - name: Run tests + run: bun test diff --git a/bun.lock b/bun.lock index 86f203a..79a8941 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,16 @@ "": { "name": "workflow-automator", "dependencies": { - "ink": "^5.0.0", - "react": "^18.2.0", + "@openstaticfish/actionflow": "github:openstaticfish/actionflow", + "ink": "^6.6.0", + "react": "^19.2.4", + "react-devtools-core": "^6.1.1", "yaml": "^2.4.0", }, "devDependencies": { "@types/bun": "latest", - "@types/react": "^18.2.0", + "@types/react": "^19.2.13", + "typescript": "^5.0.0", }, "peerDependencies": { "typescript": "^5", @@ -19,15 +22,15 @@ }, }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ=="], + + "@openstaticfish/actionflow": ["@openstaticfish/actionflow@github:openstaticfish/actionflow#df01330", { "dependencies": { "ink": "^6.6.0", "react": "^19.2.4", "react-devtools-core": "^6.1.1", "yaml": "^2.4.0" }, "peerDependencies": { "typescript": "^5" }, "bin": { "actionflow": "./dist/index.js", "af": "./dist/index.js" } }, "OpenStaticFish-actionflow-df01330"], "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/node": ["@types/node@25.2.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg=="], - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], - - "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -45,7 +48,7 @@ "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], @@ -65,15 +68,11 @@ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], - - "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + "ink": ["ink@6.6.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -81,13 +80,17 @@ "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -95,7 +98,7 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -115,8 +118,10 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/src/core/registry.test.ts b/src/core/registry.test.ts new file mode 100644 index 0000000..d43f8da --- /dev/null +++ b/src/core/registry.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test, beforeEach } from 'bun:test'; +import { WorkflowRegistry } from '../core/registry.js'; +import type { Workflow, WorkflowType } from '../models/workflow.js'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const FIXTURES_ROOT = join(__dirname, '..', 'test', 'fixtures'); + +describe('WorkflowRegistry', () => { + let registry: WorkflowRegistry; + + beforeEach(() => { + // Create registry with test fixtures + registry = new WorkflowRegistry(FIXTURES_ROOT); + }); + + describe('load()', () => { + test('should load all workflows from fixtures', async () => { + await registry.load(); + const workflows = registry.getWorkflows(); + expect(workflows.length).toBeGreaterThan(0); + }); + + test('should load all categories', async () => { + await registry.load(); + const categories = registry.getCategories(); + expect(categories.length).toBe(2); + + const categoryIds = categories.map(c => c.id).sort(); + expect(categoryIds).toEqual(['second-category', 'test-category']); + }); + + test('should parse category names correctly', async () => { + await registry.load(); + const categories = registry.getCategories(); + + const testCategory = categories.find(c => c.id === 'test-category'); + expect(testCategory?.name).toBe('Test Category'); + + const secondCategory = categories.find(c => c.id === 'second-category'); + expect(secondCategory?.name).toBe('Second Category'); + }); + }); + + describe('discoverWorkflows()', () => { + test('should discover workflows with multiple variants', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + expect(workflow).toBeDefined(); + expect(workflow?.workflowType).toBe('simple-workflow'); + expect(workflow?.variants.length).toBe(2); + + const variantNames = workflow?.variants.map(v => v.name).sort(); + expect(variantNames).toEqual(['nix', 'standard']); + }); + + test('should parse metadata correctly', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + expect(workflow?.metadata.name).toBe('Simple Test Workflow'); + expect(workflow?.metadata.description).toBe('A simple test workflow for unit testing'); + expect(workflow?.type).toBe('set'); + }); + + test('should parse secrets from metadata', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + expect(workflow?.metadata.secrets.length).toBe(1); + expect(workflow?.metadata.secrets[0]?.name).toBe('TEST_SECRET'); + expect(workflow?.metadata.secrets[0]?.description).toBe('A test secret'); + expect(workflow?.metadata.secrets[0]?.required).toBe(true); + }); + + test('should parse triggers from metadata', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + expect(workflow?.metadata.triggers.length).toBe(2); + const triggerEvents = workflow?.metadata.triggers.map(t => t.event); + expect(triggerEvents).toContain('push'); + expect(triggerEvents).toContain('pull_request'); + }); + + test('should handle workflows with inline metadata', async () => { + await registry.load(); + const workflow = registry.getWorkflow('second-category/some-workflow'); + + expect(workflow).toBeDefined(); + expect(workflow?.metadata.name).toBe('Some Workflow'); + expect(workflow?.type).toBe('set'); + }); + + test('should parse multiple secrets with required field', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/another-workflow'); + + expect(workflow?.metadata.secrets.length).toBe(2); + const secretNames = workflow?.metadata.secrets.map(s => s.name); + expect(secretNames).toContain('API_KEY'); + expect(secretNames).toContain('TOKEN'); + + // Verify required field is parsed correctly + const apiKeySecret = workflow?.metadata.secrets.find(s => s.name === 'API_KEY'); + const tokenSecret = workflow?.metadata.secrets.find(s => s.name === 'TOKEN'); + expect(apiKeySecret?.required).toBe(true); + expect(tokenSecret?.required).toBe(false); + }); + }); + + describe('getWorkflow()', () => { + test('should return undefined for non-existent workflow', async () => { + await registry.load(); + const workflow = registry.getWorkflow('non-existent/workflow'); + expect(workflow).toBeUndefined(); + }); + + test('should return correct workflow by id', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + expect(workflow).toBeDefined(); + expect(workflow?.id).toBe('test-category/simple-workflow'); + }); + }); + + describe('filterWorkflows()', () => { + test('should filter by category', async () => { + await registry.load(); + const filtered = registry.filterWorkflows({ category: 'test-category' }); + + expect(filtered.length).toBe(2); + expect(filtered.every(w => w.category.id === 'test-category')).toBe(true); + }); + + test('should filter by type', async () => { + await registry.load(); + const setWorkflows = registry.filterWorkflows({ type: 'set' as WorkflowType }); + const templateWorkflows = registry.filterWorkflows({ type: 'template' as WorkflowType }); + + expect(setWorkflows.length).toBeGreaterThan(0); + expect(templateWorkflows.length).toBeGreaterThan(0); + expect(setWorkflows.every(w => w.type === 'set')).toBe(true); + expect(templateWorkflows.every(w => w.type === 'template')).toBe(true); + }); + + test('should filter by variant', async () => { + await registry.load(); + const nixWorkflows = registry.filterWorkflows({ variant: 'nix' }); + + expect(nixWorkflows.length).toBeGreaterThan(0); + expect(nixWorkflows.every(w => w.variants.some(v => v.name === 'nix'))).toBe(true); + }); + + test('should combine multiple filters', async () => { + await registry.load(); + const filtered = registry.filterWorkflows({ + category: 'test-category', + type: 'set' as WorkflowType + }); + + expect(filtered.every(w => w.category.id === 'test-category' && w.type === 'set')).toBe(true); + }); + + test('should return all workflows when no filters applied', async () => { + await registry.load(); + const all = registry.filterWorkflows({}); + const getAll = registry.getWorkflows(); + + expect(all.length).toBe(getAll.length); + }); + }); + + describe('variant handling', () => { + test('should sort variants with standard first', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + expect(workflow?.variants[0]?.name).toBe('standard'); + }); + + test('should set correct toolchain for variants', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + const standardVariant = workflow?.variants.find(v => v.name === 'standard'); + const nixVariant = workflow?.variants.find(v => v.name === 'nix'); + + expect(standardVariant?.toolchain).toBe('standard'); + expect(nixVariant?.toolchain).toBe('nix'); + }); + + test('should include correct file paths for variants', async () => { + await registry.load(); + const workflow = registry.getWorkflow('test-category/simple-workflow'); + + const standardVariant = workflow?.variants.find(v => v.name === 'standard'); + expect(standardVariant?.filepath).toContain('simple-workflow.yml'); + expect(standardVariant?.filename).toBe('simple-workflow.yml'); + }); + }); + + describe('edge cases', () => { + test('should handle empty workflows directory', async () => { + const emptyRegistry = new WorkflowRegistry(join(__dirname, '..', 'test', 'fixtures', 'this-directory-does-not-exist-for-empty-test')); + await emptyRegistry.load(); + + expect(emptyRegistry.getWorkflows()).toEqual([]); + expect(emptyRegistry.getCategories()).toEqual([]); + }); + + test('should deduplicate workflows by id', async () => { + await registry.load(); + const workflows = registry.getWorkflows(); + const ids = workflows.map(w => w.id); + const uniqueIds = [...new Set(ids)]; + + expect(ids.length).toBe(uniqueIds.length); + }); + }); +}); diff --git a/src/core/registry.ts b/src/core/registry.ts index ab8ecef..203c456 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -37,7 +37,7 @@ function findWorkflowsRoot(): string { const WORKFLOWS_ROOT = findWorkflowsRoot(); -interface ParsedMetadata { +export interface ParsedMetadata { id?: string; category?: string; type?: WorkflowType; @@ -52,6 +52,11 @@ interface ParsedMetadata { export class WorkflowRegistry { private workflows: Map = new Map(); private categories: Map = new Map(); + private workflowsRoot: string; + + constructor(workflowsRoot?: string) { + this.workflowsRoot = workflowsRoot ?? WORKFLOWS_ROOT; + } async load(): Promise { this.workflows.clear(); @@ -69,15 +74,24 @@ export class WorkflowRegistry { private async discoverCategories(): Promise { const categories: Category[] = []; - const entries = await readdir(WORKFLOWS_ROOT, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - categories.push({ - id: entry.name, - name: this.formatCategoryName(entry.name), - description: '', - path: join(WORKFLOWS_ROOT, entry.name), - }); + try { + const entries = await readdir(this.workflowsRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + categories.push({ + id: entry.name, + name: this.formatCategoryName(entry.name), + description: '', + path: join(this.workflowsRoot, entry.name), + }); + } + } catch (error) { + // Only ENOENT (directory doesn't exist) is expected - other errors should be logged + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + // Expected - directory doesn't exist, return empty categories + } else { + console.warn('Warning: Could not read workflows directory:', error); + } } return categories; } @@ -259,12 +273,15 @@ export class WorkflowRegistry { const secrets: SecretRequirement[] = []; const secretsBlock = content.match(/# secrets:[\s\S]*?(?=\n# [a-z]|\n\n|$)/i); if (secretsBlock) { - const regex = /#\s+- name:\s*(\S+)[\s\S]*?#\s+description:\s*(.+)/g; - for (const item of secretsBlock[0].matchAll(regex)) { + // Match individual secret entries with name, description, and optional required field + const entryRegex = /#\s+- name:\s*(\S+)[\s\S]*?#\s+description:\s*([^\n]+)(?:[\s\S]*?#\s+required:\s*(true|false))?/g; + for (const item of secretsBlock[0].matchAll(entryRegex)) { const name = item[1]; const description = item[2]; + const requiredStr = item[3]; if (!name || !description) continue; - secrets.push({ name, description: description.trim(), required: true }); + const required = requiredStr ? requiredStr.trim() === 'true' : true; + secrets.push({ name, description: description.trim(), required }); } } @@ -332,7 +349,11 @@ export class WorkflowRegistry { private async safeReadDir(path: string): Promise { try { return await readdir(path); - } catch { + } catch (error) { + // Only ENOENT (directory doesn't exist) is expected - other errors should be logged + if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { + console.warn(`Warning: Could not read directory ${path}:`, error); + } return null; } } diff --git a/src/test/fixtures/second-category/some-workflow/some-workflow.yml b/src/test/fixtures/second-category/some-workflow/some-workflow.yml new file mode 100644 index 0000000..c79a7fc --- /dev/null +++ b/src/test/fixtures/second-category/some-workflow/some-workflow.yml @@ -0,0 +1,13 @@ +# id: second-category/some-workflow +# category: second-category +# type: set +# name: Some Workflow +# description: A workflow in the second category + +name: Some Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/src/test/fixtures/test-category/another-workflow/another-workflow.yml b/src/test/fixtures/test-category/another-workflow/another-workflow.yml new file mode 100644 index 0000000..bd848b7 --- /dev/null +++ b/src/test/fixtures/test-category/another-workflow/another-workflow.yml @@ -0,0 +1,30 @@ +# --- +# id: test-category/another-workflow +# category: test-category +# type: template +# name: Another Test Workflow +# description: Another workflow with inline metadata +# targetPath: .github/workflows/another.yml +# secrets: +# - name: API_KEY +# description: API key for service +# required: true +# - name: TOKEN +# description: Authentication token +# required: false +# triggers: +# - schedule +# variants: +# - name: standard +# description: Basic template +# --- + +name: Another Test +on: + schedule: + - cron: '0 0 * * *' +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/src/test/fixtures/test-category/simple-workflow/simple-workflow-nix.yml b/src/test/fixtures/test-category/simple-workflow/simple-workflow-nix.yml new file mode 100644 index 0000000..f64c348 --- /dev/null +++ b/src/test/fixtures/test-category/simple-workflow/simple-workflow-nix.yml @@ -0,0 +1,9 @@ +name: Simple Test (Nix) +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v22 + - run: nix develop diff --git a/src/test/fixtures/test-category/simple-workflow/simple-workflow.yml b/src/test/fixtures/test-category/simple-workflow/simple-workflow.yml new file mode 100644 index 0000000..8027db7 --- /dev/null +++ b/src/test/fixtures/test-category/simple-workflow/simple-workflow.yml @@ -0,0 +1,28 @@ +# --- +# id: test-category/simple-workflow +# category: test-category +# type: set +# name: Simple Test Workflow +# description: A simple test workflow for unit testing +# targetPath: .github/workflows/simple.yml +# secrets: +# - name: TEST_SECRET +# description: A test secret +# required: true +# triggers: +# - push +# - pull_request +# variants: +# - name: standard +# description: Standard version +# - name: nix +# description: Nix version +# --- + +name: Simple Test +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/src/tui/utils/install-workflow.test.ts b/src/tui/utils/install-workflow.test.ts new file mode 100644 index 0000000..a6b3b2d --- /dev/null +++ b/src/tui/utils/install-workflow.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { installWorkflow } from './install-workflow.js'; +import type { Workflow, WorkflowVariant } from '../../models/workflow.js'; +import { mkdtemp, writeFile, readFile, access, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { constants } from 'node:fs'; + +async function expectFileNotToExist(filePath: string): Promise { + let fileExists = false; + try { + await access(filePath, constants.F_OK); + fileExists = true; + } catch { + // Expected - file should not exist + } + expect(fileExists).toBe(false); +} + +describe('installWorkflow', () => { + let tempDir: string; + let sourceDir: string; + let mockWorkflow: Workflow; + let mockVariant: WorkflowVariant; + + beforeEach(async () => { + // Create temporary directories for testing + tempDir = await mkdtemp(join(tmpdir(), 'actionflow-test-')); + sourceDir = await mkdtemp(join(tmpdir(), 'actionflow-source-')); + + // Create a test workflow file + const workflowContent = `name: Test Workflow +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +`; + await writeFile(join(sourceDir, 'test-workflow.yml'), workflowContent); + + mockVariant = { + name: 'standard', + filename: 'test-workflow.yml', + filepath: join(sourceDir, 'test-workflow.yml'), + toolchain: 'standard', + description: 'Test variant', + installRelativePath: '.github/workflows/test-workflow.yml', + }; + + mockWorkflow = { + id: 'test/test-workflow', + category: { + id: 'test', + name: 'Test', + description: '', + path: '/test', + }, + workflowType: 'test-workflow', + type: 'set', + variants: [mockVariant], + metadata: { + name: 'Test Workflow', + description: 'A test workflow', + secrets: [], + inputs: [], + triggers: [], + estimatedSetupTime: '0 minutes', + }, + }; + }); + + afterEach(async () => { + // Cleanup temporary directories + try { + await rm(tempDir, { recursive: true }); + await rm(sourceDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('basic installation', () => { + test('should install workflow successfully', async () => { + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('Successfully installed'); + expect(result.details?.created).toBe(true); + expect(result.details?.overwritten).toBe(false); + + // Verify file was created + const targetFile = join(tempDir, '.github', 'workflows', 'test-workflow.yml'); + await access(targetFile, constants.F_OK); + + // Verify content + const content = await readFile(targetFile, 'utf-8'); + expect(content).toContain('name: Test Workflow'); + }); + + test('should use default path when installRelativePath not provided', async () => { + const variantWithoutPath: WorkflowVariant = { + ...mockVariant, + installRelativePath: undefined, + }; + + const result = await installWorkflow(mockWorkflow, variantWithoutPath, { + targetPath: tempDir, + }); + + expect(result.success).toBe(true); + + // Verify file was created at default path + const targetFile = join(tempDir, '.github', 'workflows', 'test-workflow.yml'); + await access(targetFile, constants.F_OK); + }); + }); + + describe('overwrite behavior', () => { + test('should fail when file exists without force flag', async () => { + // First install + await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + }); + + // Second install should fail + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('already exists'); + }); + + test('should overwrite when force flag is true', async () => { + // First install + await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + }); + + // Modify the source file + const modifiedContent = `name: Modified Workflow +on: [push] +jobs: + test: + runs-on: ubuntu-latest +`; + await writeFile(join(sourceDir, 'test-workflow.yml'), modifiedContent); + + // Second install with force + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + force: true, + }); + + expect(result.success).toBe(true); + expect(result.details?.overwritten).toBe(true); + expect(result.details?.created).toBe(false); + + // Verify content was updated + const targetFile = join(tempDir, '.github', 'workflows', 'test-workflow.yml'); + const content = await readFile(targetFile, 'utf-8'); + expect(content).toContain('name: Modified Workflow'); + }); + }); + + describe('dry-run mode', () => { + test('should not create file in dry-run mode', async () => { + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('Would install'); + expect(result.details?.created).toBe(true); + + // Verify file was NOT created + const targetFile = join(tempDir, '.github', 'workflows', 'test-workflow.yml'); + await expectFileNotToExist(targetFile); + }); + + test('should report overwrite in dry-run mode when file exists', async () => { + // First install + await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + }); + + // Dry run install + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + dryRun: true, + force: true, + }); + + expect(result.success).toBe(true); + expect(result.details?.overwritten).toBe(true); + expect(result.details?.created).toBe(false); + }); + }); + + describe('error handling', () => { + test('should handle missing source file', async () => { + const variantWithBadPath: WorkflowVariant = { + ...mockVariant, + filepath: '/non-existent/file.yml', + }; + + const result = await installWorkflow(mockWorkflow, variantWithBadPath, { + targetPath: tempDir, + }); + + expect(result.success).toBe(false); + expect(result.message).toBeTruthy(); + }); + + test('should handle invalid target path', async () => { + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: '/non-existent-directory/subdir', + }); + + // This may succeed or fail depending on permissions, so just check we get a result + expect(result).toBeDefined(); + expect(result.message).toBeTruthy(); + }); + }); + + describe('result details', () => { + test('should include source and target file paths in result', async () => { + const result = await installWorkflow(mockWorkflow, mockVariant, { + targetPath: tempDir, + }); + + expect(result.details?.sourceFile).toBe(mockVariant.filepath); + expect(result.details?.targetFile).toContain('.github/workflows/test-workflow.yml'); + }); + }); +}); diff --git a/src/tui/utils/install-workflow.ts b/src/tui/utils/install-workflow.ts index 83c3d61..ab2b385 100644 --- a/src/tui/utils/install-workflow.ts +++ b/src/tui/utils/install-workflow.ts @@ -82,9 +82,10 @@ export async function installWorkflow( }, }; } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; return { success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred', + message: `Failed to install workflow: ${message}`, }; } }