diff --git a/README.md b/README.md index 387ca97..dc20cf9 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,12 @@ jobs: comment-on-pr: "yes" # Optional: Whether to comment test results on PRs (default: 'no'). ``` -To trigger specific stories, update the action with the story slug and optional test configuration: +To trigger specific stories, you can either: + +- provide an explicit list of story slugs via `stories`, or +- provide a glob-style `pattern` that the backend will use to select matching stories. + +#### Using explicit `stories` ```yaml name: Heal.dev CI @@ -81,14 +86,45 @@ jobs: comment-on-pr: "yes" # Optional: Whether to comment test results on PRs (default: 'no'). ``` +To trigger stories using a **glob-style pattern** instead of listing them explicitly, use the `pattern` input (mutually exclusive with `stories`): + +```yaml +name: Heal.dev CI +on: + push: + +jobs: + heal-dev: + name: Heal.dev + runs-on: ubuntu-latest + steps: + - name: Trigger Heal Suite Execution with pattern + uses: heal-dev/trigger@v1 + with: + api-token: ${{ secrets.HEAL_API_TOKEN }} # Required: Your Heal API token. + suite: "project-test/suite-test" # Required: The slug of the project and suite `project-slug-name/suite-slug-name`. + pattern: "Button*" # Glob-style pattern for story slugs. Cannot be used together with `stories`. + test-config: | # Optional: global test configuration, including onPremiseBrowser + { + "onPremiseBrowser": true, + "entrypoint": "https://app-staging.heal.dev", + "variables": { + "buttonName": "Pattern Run" + } + } + wait-for-results: "yes" # Optional: Wait for results (default: 'yes'). + comment-on-pr: "yes" # Optional: Whether to comment test results on PRs (default: 'no'). +``` + ## Inputs | Input | Required | Description | | ------------------ | -------- | --------------------------------------------------------------------------------------- | | `api-token` | ✅ | Your Heal API token (you can create one [here](https://app.heal.dev/organisation/keys)) | | `suite` | ✅ | The slug name of the test suite (e.g., project-slug-name/suite-slug-name). | -| `test-config` | ❌ | Optional JSON payload to specify global test configuration. | -| `stories` | ❌ | Optional JSON payload to specify story slugs and override global test configurations | +| `test-config` | ❌ | Optional JSON payload to specify global test configuration (supports `entrypoint`, `variables`, and `onPremiseBrowser`). | +| `stories` | ❌ | Optional JSON payload to specify story slugs and override global test configurations. Mutually exclusive with `pattern`. | +| `pattern` | ❌ | Optional glob-style pattern for story slugs. Mutually exclusive with `stories`. | | `wait-for-results` | ❌ | Whether to wait for results (default: `yes`). | | `comment-on-pr` | ❌ | Whether to comment test results on PR (default: `no`). | diff --git a/action.yml b/action.yml index beac5d7..77bc059 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,9 @@ inputs: stories: description: "List of stories to run in JSON format" required: false + pattern: + description: "Glob-style pattern of stories to run (mutually exclusive with 'stories')" + required: false outputs: execution-id: description: "The ID of the execution" diff --git a/index.js b/index.js index 04a23dc..71833e1 100644 --- a/index.js +++ b/index.js @@ -136,12 +136,26 @@ function validateStoriesFormat(config) { if (testConfig.variables && typeof testConfig.variables !== 'object') { throw new Error(`Invalid test-config: "variables" must be an object if provided. Found ${typeof testConfig.variables}.`); } + if (testConfig.onPremiseBrowser && typeof testConfig.onPremiseBrowser !== 'boolean') { + throw new Error(`Invalid test-config: "onPremiseBrowser" must be a boolean if provided. Found ${typeof testConfig.onPremiseBrowser}.`); + } + } + + if (config.pattern && typeof config.pattern !== 'string') { + throw new Error(`Invalid pattern: "pattern" must be a string if provided. Found ${typeof config.pattern}.`); } if (config.stories && !Array.isArray(config.stories)) { throw new Error('Invalid stories: "stories" must be an array.'); } + const hasPattern = config.pattern !== undefined && config.pattern !== ''; + const hasStories = config.stories && config.stories.length > 0; + + if (hasPattern && hasStories) { + throw new Error('Cannot specify both pattern and stories array. Use either pattern or stories, not both.'); + } + if (config.stories) { config.stories.forEach(story => { if (typeof story.slug !== 'string') { @@ -155,6 +169,9 @@ function validateStoriesFormat(config) { if (testConfig.variables && typeof testConfig.variables !== 'object') { throw new Error(`Invalid test-config: "variables" must be an object if provided. Found ${typeof testConfig.variables}.`); } + if (testConfig.onPremiseBrowser && typeof testConfig.onPremiseBrowser !== 'boolean') { + throw new Error(`Invalid test-config: "onPremiseBrowser" must be a boolean if provided. Found ${typeof testConfig.onPremiseBrowser}.`); + } } }); @@ -183,6 +200,7 @@ async function run() { const payload = core.getInput('payload'); const stories = core.getInput('stories'); const testConfig = core.getInput('test-config'); + const pattern = core.getInput('pattern'); if (suiteId && suite) { core.setFailed('Please provide either suite-id or suite, not both.'); @@ -193,13 +211,13 @@ async function run() { return; } - if (suiteId && (stories || testConfig)) { - core.setFailed('When "suite-id" is provided, "stories" should come from "payload", not "stories" or "test-config".'); + if (suiteId && (stories || testConfig || pattern)) { + core.setFailed('When "suite-id" is provided, "stories" or "pattern" should come from "payload", not "stories", "pattern" or "test-config".'); return; } if (suite && payload) { - core.setFailed('When "suite" is provided, "stories" should come from "stories", not "payload".'); + core.setFailed('When "suite" is provided, "stories" or "pattern" should come from "stories"/"pattern", not "payload".'); return; } @@ -228,6 +246,9 @@ async function run() { if (inputStories) { validatedPayload.stories = JSON.parse(inputStories); } + if (pattern) { + validatedPayload.pattern = pattern; + } if (testConfig) { validatedPayload["test-config"] = JSON.parse(testConfig); } diff --git a/test/index.test.js b/test/index.test.js index 463220d..b7e21d8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -166,7 +166,18 @@ describe('GitHub Action Tests', () => { await run(); - expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('When "suite-id" is provided, "stories" should come from "payload", not "stories" or "test-config".')); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('When \"suite-id\" is provided, \"stories\" or \"pattern\" should come from \"payload\", not \"stories\", \"pattern\" or \"test-config\".')); + }); + it('should fail if suite-id provided with pattern instead of payload', async () => { + core.getInput = jest.fn().mockImplementation((name) => { + if (name === 'suite-id') return 'test-suite-id'; + if (name === 'pattern') return 'story-*'; + return null; + }); + + await run(); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('When "suite-id" is provided, "stories" or "pattern" should come from "payload", not "stories", "pattern" or "test-config".')); }); it('should fail if suite provided with payload instead of stories', async () => { @@ -178,7 +189,7 @@ describe('GitHub Action Tests', () => { await run(); - expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('When "suite" is provided, "stories" should come from "stories", not "payload".')); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('When "suite" is provided, "stories" or "pattern" should come from "stories"/"pattern", not "payload".')); }); it('should handle API errors gracefully', async () => { core.getInput = jest.fn().mockImplementation((name) => { @@ -243,6 +254,24 @@ describe('GitHub Action Tests', () => { expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid stories: "stories" must be an array.')); }); + it('should fail if both pattern and stories are provided for a suite', async () => { + core.getInput = jest.fn().mockImplementation((name) => { + if (name === 'suite') return 'test/test-suite'; + if (name === 'stories') { + return JSON.stringify([ + { + slug: 'story1' + } + ]); + } + if (name === 'pattern') return 'story-*'; + return null; + }); + + await run(); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Cannot specify both pattern and stories array. Use either pattern or stories, not both.')); + }); it('should fail if stories have invalid structure', async () => { core.getInput = jest.fn().mockImplementation((name) => { @@ -306,6 +335,19 @@ describe('GitHub Action Tests', () => { expect(core.setOutput).toHaveBeenCalledWith('execution-url', 'http://test.com/execution'); expect(global.fetch).toHaveBeenCalledTimes(2); // One for trigger, one for status }, 20000); + it('should execute the full workflow successfully - given a suite pattern', async () => { + core.getInput = jest.fn().mockImplementation((name) => { + if (name === 'suite') return 'test/test-suite'; + if (name === 'pattern') return 'story-*'; + return null; + }); + + await run(); + + expect(core.setOutput).toHaveBeenCalledWith('execution-id', 'test-execution'); + expect(core.setOutput).toHaveBeenCalledWith('execution-url', 'http://test.com/execution'); + expect(global.fetch).toHaveBeenCalledTimes(2); // One for trigger, one for status + }, 20000); it('should fail if global "test-config" has an invalid "entrypoint"', async () => { core.getInput = jest.fn().mockImplementation((name) => { if (name === 'suite') return 'test/test-suite';