Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`). |

Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 24 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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}.`);
}
}

});
Expand Down Expand Up @@ -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.');
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
Expand Down
46 changes: 44 additions & 2 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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';
Expand Down
Loading